diff --git a/accounts/tests/test_api_tokens.py b/accounts/tests/test_api_tokens.py index ca7bdd19..177a0635 100644 --- a/accounts/tests/test_api_tokens.py +++ b/accounts/tests/test_api_tokens.py @@ -198,11 +198,11 @@ class AdminApiTokenTests(TestCase): 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")} + def tokens(name): + return page.xpath(f"//div[contains(@class, 'list-group-item')][.//a[normalize-space()='{name}']]" + "//a[contains(@href, '#apitokens')]") # 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(), "") + (link,) = tokens("tok-user") + self.assertEqual(link.text_content().strip(), "1 API token") + self.assertEqual(link.get("href"), reverse("accounts:account", args=[self.user.id]) + "#apitokens") + self.assertEqual(tokens("tok-admin"), []) diff --git a/admin/forms.py b/admin/forms.py index f31149f2..aea6af32 100644 --- a/admin/forms.py +++ b/admin/forms.py @@ -13,6 +13,21 @@ from instances.models import Instance from .models import Permission +# what each permission lets a user do, shown beside its switch +PERMISSION_HELP = { + "change_password": _("Change their own password on their profile page"), + "clone_instances": _("Clone the VMs they may change and the templates they may view, within their quota"), + "passwordless_console": _("Open a console without the VM's console password"), + "snapshot_instances": _("Take, revert and delete snapshots of the VMs they may change"), + "view_instances": _("See every VM and host in the lists, read-only"), +} + + +def permission_rows(field): + """Each permission's checkbox of a bound field with what it lets a user do.""" + return [(box, PERMISSION_HELP.get(box.data["value"].instance.codename, "")) for box in field] + + class GroupForm(forms.ModelForm): permissions = forms.ModelMultipleChoiceField( widget=forms.CheckboxSelectMultiple, @@ -32,6 +47,9 @@ class GroupForm(forms.ModelForm): if instance and instance.id: self.fields["users"].initial = self.instance.user_set.all() + def permission_rows(self): + return permission_rows(self["permissions"]) + def save_m2m(self): self.instance.user_set.set(self.cleaned_data["users"]) @@ -76,6 +94,12 @@ class UserForm(UniqueEmailMixin, forms.ModelForm): labels = {"is_staff": _("Template editor")} help_texts = {"is_staff": _("May change and delete the template VMs given to them")} + def permission_rows(self): + return permission_rows(self["user_permissions"]) + + def identity_has_errors(self): + return any(self.has_error(name) for name in ("username", "password", "first_name", "last_name", "email")) + class UserCreateForm(UserForm): password = forms.CharField(widget=forms.PasswordInput) diff --git a/admin/templates/admin/group_form.html b/admin/templates/admin/group_form.html new file mode 100644 index 00000000..fb763810 --- /dev/null +++ b/admin/templates/admin/group_form.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% load django_bootstrap5 %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block page_heading %}{{ title }}{% endblock page_heading %} + +{% block content %} +{# as the user form: what the members may do first, then who they are #} +
+ {% csrf_token %} + {% bootstrap_form_errors form type="non_fields" %} + {% bootstrap_field form.name %} + +

{% trans "What can its members do?" %}

+

{% trans "Each member has these permissions besides their own." %}

+
+ {% for box, help in form.permission_rows %} +
+ + +
+ {% endfor %} + {% for error in form.permissions.errors %}
{{ error }}
{% endfor %} +
+ +
+ {% trans "Members" %} +
+ {% for box in form.users %} + + {% endfor %} +
+ {% for error in form.users.errors %}
{{ error }}
{% endfor %} +
+ +
+ {% trans "Cancel" %} + +
+
+{% endblock content %} diff --git a/admin/templates/admin/group_list.html b/admin/templates/admin/group_list.html index 151ed739..8b060b2f 100644 --- a/admin/templates/admin/group_list.html +++ b/admin/templates/admin/group_list.html @@ -2,60 +2,60 @@ {% load i18n %} {% load static %} {% load bootstrap_icons %} -{% block title %}{% trans "Users" %}{% endblock %} + +{% block title %}{% trans "Groups" %}{% endblock %} + +{% block page_heading %}{% trans "Groups" %}{% endblock page_heading %} + +{% block page_heading_extra %} + + {% trans "New group" %} + +{% endblock page_heading_extra %} + {% block content %} -
-
- - {% bs_icon 'plus-circle-fill' %} - - {% include 'search_block.html' %} -

{% trans "Groups" %}

+{% if not groups %} +
+ {% bs_icon 'exclamation-triangle' %} {% trans "There are no groups yet." %}
-
-
- {% if not groups %} -
-
- {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "You don't have any groups" %} -
+{% else %} +
+
+ {% trans "Groups" %} {{ groups|length }} +
- {% else %} -
-
- - - - - - - - - {% for group in groups %} - - - - - {% endfor %} - -
{% trans "Group Name" %}{% trans "Actions" %}
- {{ group.name }} - - -
+
+ {% for group in groups %} +
+
+ +
+ {% blocktrans count count=group.members %}{{ count }} member{% plural %}{{ count }} members{% endblocktrans %} + · {% blocktrans count count=group.perms %}{{ count }} permission{% plural %}{{ count }} permissions{% endblocktrans %} +
+
+
+ {% trans "Edit" %} + + +
+
+
+ {% endfor %}
- {% endif %} -
+
+ +
+
+{% endif %} {% endblock content %} {% block script %} - +{% if groups %}{% endif %} {% endblock script %} diff --git a/admin/templates/admin/logs.html b/admin/templates/admin/logs.html index f9706224..a17726d0 100644 --- a/admin/templates/admin/logs.html +++ b/admin/templates/admin/logs.html @@ -8,61 +8,59 @@ {% block page_heading %}{% trans "Logs" %}{% endblock page_heading %} {% block page_heading_extra %} -{# the search runs on the server over every row; the download takes the same rows #} - + {% trans "Excel" %} {% endblock page_heading_extra %} {% block content %} -
-
- {% if not logs %} -
- {% bs_icon 'exclamation-triangle'%} - {% if q or failed %}{% trans "No log rows match." %}{% else %}{% trans "There are no log rows yet." %}{% endif %} -
- {% else %} -
- - +
+ {# the search runs on the server over every row; the download takes the same rows #} + + {% trans "Logs" %} {{ logs.paginator.count }} + +
+ + +
+ + + {% if not logs %} +
+ {% if q or failed %}{% trans "No log rows match." %}{% else %}{% trans "There are no log rows yet." %}{% endif %} +
+ {% else %} +
+
+ + + + + + + + + + + + + + {% for log in logs %} - - - - - - - - + + + + + + + + - - - {% for log in logs %} - - - - - - - - - - - {% endfor %} - -
#{% blocktrans %}Date ({{ time_zone }}){% endblocktrans %}{% trans "User" %}{% trans "Host" %}{% trans "Instance" %}{% trans "Message" %}{% trans "Result" %}{% trans "IP" %}
#{% blocktrans %}Date ({{ time_zone }}){% endblocktrans %}{% trans "User" %}{% trans "Host" %}{% trans "Instance" %}{% trans "Message" %}{% trans "Result" %}{% trans "IP" %}{{ log.id }}{{ log.date|date:"Y-m-d H:i:s" }}{{ log.user }}{{ log.host }}{% if log.vm_pk %}{{ log.instance }}{% else %}{{ log.instance }}{% endif %}{{ log.message }}{% if log.success is None %}{% trans "Unknown" %}{% elif log.success %}{% trans "Done" %}{% else %}{% trans "Failed" %}{% endif %}{{ log.ip|default:"" }}
{{ log.id }}{{ log.date|date:"Y-m-d H:i:s" }}{{ log.user }}{{ log.host }}{% if log.vm_pk %}{{ log.instance }}{% else %}{{ log.instance }}{% endif %}{{ log.message }}{% if log.success is None %}{% trans "Unknown" %}{% elif log.success %}{% trans "Done" %}{% else %}{% trans "Failed" %}{% endif %}{{ log.ip|default:"" }}
-
- {% bootstrap_pagination logs extra=query %} - {% endif %} -
+ {% endfor %} + + +
+ {% endif %}
+{% if logs %}{% bootstrap_pagination logs extra=query %}{% endif %} {% endblock %} diff --git a/admin/templates/admin/tombstone_list.html b/admin/templates/admin/tombstone_list.html index 46e4e097..b43d6929 100644 --- a/admin/templates/admin/tombstone_list.html +++ b/admin/templates/admin/tombstone_list.html @@ -2,66 +2,56 @@ {% load i18n %} {% load static %} {% load bootstrap_icons %} + {% block title %}{% trans "Removed VMs" %}{% endblock %} + +{% block page_heading %}{% trans "Removed VMs" %}{% endblock page_heading %} + {% block content %} -
-
- {% include 'search_block.html' %} -

{% trans "Removed VMs" %}

-

{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}

+

{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}

+{% if not tombstones %} +
+ {% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
-
-
- {% if not tombstones %} -
-
- {% bs_icon 'info-circle' %} {% trans "No removed VMs" %} -
+{% else %} +
+
+ {% trans "Removed VMs" %} {{ tombstones|length }} +
- {% else %} -
-
- - - - - - - - - - - - - - {% for tombstone in tombstones %} - - - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "UUID" %}{% trans "Host" %}{% trans "Owners" %}{% trans "Removed" %}{% trans "Expires" %}{% trans "Actions" %}
- {{ tombstone.name }} - {% if tombstone.is_template %}{% trans "Template" %}{% endif %} - {{ tombstone.uuid }}{{ tombstone.host }}{{ tombstone.owner_names|join:", " }}{{ tombstone.removed|date:"Y-m-d H:i" }}{{ tombstone.expires|date:"Y-m-d H:i" }} - -
+
+ {% for tombstone in tombstones %} +
+
+
+ {{ tombstone.name }} + {% if tombstone.is_template %}{% trans "Template" %}{% endif %} +
{{ tombstone.uuid }}
+
+
+
+ {% trans "Left" %} {{ tombstone.host }} + · {% trans "Owners" %}: {{ tombstone.owner_names|join:", "|default:"–" }} +
+
+ {% trans "Removed" %} {{ tombstone.removed|date:"Y-m-d H:i" }} + · {% trans "expires" %} {{ tombstone.expires|date:"Y-m-d H:i" }} +
+
+ +
+ {% endfor %}
- {% endif %} -
+
+ +
+
+{% endif %} {% endblock content %} {% block script %} - +{% if tombstones %}{% endif %} {% endblock script %} diff --git a/admin/templates/admin/user_form.html b/admin/templates/admin/user_form.html index bf2e9977..54d8e22a 100644 --- a/admin/templates/admin/user_form.html +++ b/admin/templates/admin/user_form.html @@ -8,29 +8,134 @@ {% block page_heading %}{{ title }}{% endblock page_heading %} {% block content %} -
-
-
- {% csrf_token %} - {% bootstrap_form user_form layout='horizontal' %} - {% if edited_user %} -
- {% trans "Password" %} -
- {% if edited_user.has_usable_password %}{% trans "Set" %}{% else %}{% trans "Not set" %}{% endif %} - {% trans "Change password" %} -
-
- {% endif %} - {% bootstrap_form attributes_form layout='horizontal' %} -
-
- - {% bs_icon 'x-square-fill' %} {% trans "Cancel" %} - +{# what the user may do comes first; who they are and their quota fold away #} +{% if edited_user %} +
+ {{ edited_user.username }} + {% if edited_user.email %}{{ edited_user.email }}{% endif %} +
{% trans "Last login" %} {{ edited_user.last_login|date:"Y-m-d H:i"|default:_("never") }}
+
+{% endif %} + +
+ {% csrf_token %} + {% bootstrap_form_errors user_form type="non_fields" %} + +
+ + +
{% trans "Turn off to block the user while keeping the account." %}
+
+ +

+ {% if edited_user %}{% blocktrans with name=edited_user.username %}What can {{ name }} do?{% endblocktrans %}{% else %}{% trans "What can the user do?" %}{% endif %} +

+

{% trans "The permissions of the user's groups apply too." %}

+ +
+
+ +
+
+
-
-{% endblock content %} \ No newline at end of file + +
+ {% trans "Groups" %} +
+ {% for box in user_form.groups %} + + {% empty %} + {% trans "There are no groups yet." %} + {% endfor %} +
+ {% for error in user_form.groups.errors %}
{{ error }}
{% endfor %} +
+ +
+ {% trans "Own permissions" %} +

{% trans "Off grants nothing here; it takes away nothing a group grants." %}

+
+ {% for box, help in user_form.permission_rows %} +
+ + +
+ {% endfor %} +
+ {% for error in user_form.user_permissions.errors %}
{{ error }}
{% endfor %} +
+ +
+ + {% trans "Name and password" %} + {% if edited_user %} + + {{ edited_user.username }}{% if edited_user.email %} · {{ edited_user.email }}{% endif %} · + {% if edited_user.has_usable_password %}{% trans "password set" %}{% else %}{% trans "no password" %}{% endif %} + + {% endif %} + +
+ {% bootstrap_field user_form.username %} + {% if user_form.password %}{% bootstrap_field user_form.password %}{% endif %} +
+
{% bootstrap_field user_form.first_name %}
+
{% bootstrap_field user_form.last_name %}
+
+ {% bootstrap_field user_form.email %} + {% if edited_user %} +
+ {% trans "Password" %}: {% if edited_user.has_usable_password %}{% trans "Set" %}{% else %}{% trans "Not set" %}{% endif %} + {% trans "Change password" %} +
+ {% endif %} +
+
+ + {% with quota=attributes_form.instance %} +
+ + {% trans "Quota" %} + + {% if quota.max_instances == -1 %}{% trans "any number of VMs" %}{% else %}{% blocktrans count count=quota.max_instances %}{{ count }} VM{% plural %}{{ count }} VMs{% endblocktrans %}{% endif %} · + {% if quota.max_cpus == -1 %}{% trans "any vCPUs" %}{% else %}{% blocktrans count count=quota.max_cpus %}{{ count }} vCPU{% plural %}{{ count }} vCPUs{% endblocktrans %}{% endif %} · + {% if quota.max_memory == -1 %}{% trans "any memory" %}{% else %}{{ quota.max_memory }} MiB{% endif %} · + {% if quota.max_disk_size == -1 %}{% trans "any disk size" %}{% else %}{{ quota.max_disk_size }} GiB{% endif %} + + +
+

{% trans "For each limit: -1 means no limit, 0 none allowed." %}

+
+
{% bootstrap_field attributes_form.max_instances show_help=False %}
+
{% bootstrap_field attributes_form.max_cpus show_help=False %}
+
{% bootstrap_field attributes_form.max_memory show_help=False %}
+
{% bootstrap_field attributes_form.max_disk_size show_help=False %}
+
+
+
+ {% endwith %} + +
+ {% trans "Cancel" %} + +
+ +{% endblock content %} diff --git a/admin/templates/admin/user_list.html b/admin/templates/admin/user_list.html index 32022b12..5d4c35fa 100644 --- a/admin/templates/admin/user_list.html +++ b/admin/templates/admin/user_list.html @@ -9,78 +9,84 @@ {% block page_heading %}{{ title }}{% endblock page_heading %} {% block page_heading_extra %} - - {% bs_icon 'plus-circle-fill' %} + + {% trans "New user" %} -{% include 'search_block.html' %} {% endblock page_heading_extra %} {% block content %} -
- {% if not users %} -
-
- {% bs_icon 'exclamation-triangle' %} {% trans "Warning" %}: {% trans "You don't have any user" %} +
+
+ {% trans "Users" %} {{ users|length }} + +
+
+ {% for user in users %} + {% has_perm user 'instances.clone_instances' as can_clone %} +
+
+
+ {{ user.username }} +
+ {% if user.is_superuser %} + {% trans "Superuser" %} + {% elif user.is_staff %} + {% trans "Template editor" %} + {% else %} + {% trans "User" %} + {% endif %} + {% if user.is_active %} + {% trans "Active" %} + {% else %} + {% trans "Blocked" %} + {% endif %} +
+
+
+
+ {% for group in user.groups.all %}{{ group.name }}{% endfor %} + {# a superuser has every permission: only a granted clone permission is news #} + {% if can_clone and not user.is_superuser %}{% trans "Can clone" %}{% endif %} + {% if user.api_tokens %} + {% blocktrans count count=user.api_tokens %}{{ count }} API token{% plural %}{{ count }} API tokens{% endblocktrans %} + {% endif %} + {% if not user.groups.all and not user.api_tokens %}{% if user.is_superuser or not can_clone %} + {% trans "No groups or API tokens" %} + {% endif %}{% endif %} +
+
+ {% trans "Last login" %}: {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }} +
+
+
+
+ {% trans "Edit" %} + + +
+
- {% else %} -
-
- - - - - - - - - - - - - - - {% for user in users %} - {% has_perm user 'instances.clone_instances' as can_clone %} - - - - - - - - - - - {% endfor %} - -
{% trans "Username" %}{% trans "Status" %}{% trans "Last Login" %}{% trans "Template editor" %}{% trans "Superuser" %}{% trans "Can Clone" %}{% trans "API tokens" %}{% trans "Actions" %}
- {{ user.username }} - - {% if user.is_active %} - {% trans "Active" %} - {% else %} - {% trans "Blocked" %} - {% endif %} - {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}{% 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' %} - {% bs_icon 'pencil-fill' %} - {% if user.is_active %} -
{% csrf_token %} - -
- {% else %} -
{% csrf_token %} - -
- {% endif %} - {% bs_icon 'x-circle-fill' %} -
-
-
-
- {% endif %} + {% endfor %} +
+
+ +
{% endblock content %} diff --git a/admin/tests/test_logs_page.py b/admin/tests/test_logs_page.py index 43c124bd..26c8155d 100644 --- a/admin/tests/test_logs_page.py +++ b/admin/tests/test_logs_page.py @@ -50,7 +50,7 @@ class LogsPageTests(TestCase): def test_the_failed_rows_only_and_marked(self): page = self.page(failed="1") self.assertEqual([row[2] for row in self.rows(page)], ["alice"]) - self.assertIn("table-danger", page.xpath("//tbody/tr")[0].get("class")) + self.assertTrue(page.xpath("//tbody/tr[1]//span[contains(@class, 'text-bg-danger')]")) def test_the_pages_keep_the_search(self): page = self.page(q="bob") diff --git a/admin/tests/test_tombstones.py b/admin/tests/test_tombstones.py index f1a1aef5..11e793ff 100644 --- a/admin/tests/test_tombstones.py +++ b/admin/tests/test_tombstones.py @@ -38,8 +38,7 @@ class TombstoneTests(TestCase): self.assertContains(response, "ts-vm") self.assertContains(response, UUID) self.assertContains(response, "ts-owner") - self.assertContains(response, "ts-host", html=True) - self.assertContains(response, 'Host', html=True) + self.assertContains(response, 'ts-host', html=True) def test_only_superusers_see_or_remove_them(self): self.client.force_login(self.owner) diff --git a/admin/tests/test_user_pages.py b/admin/tests/test_user_pages.py index a4b3b62f..bbfd8ada 100644 --- a/admin/tests/test_user_pages.py +++ b/admin/tests/test_user_pages.py @@ -44,7 +44,7 @@ class UserPagesTests(TestCase): def test_the_form_tells_the_password_state_and_links_its_page(self): page = html.fromstring(self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content) text = " ".join(page.text_content().split()) - self.assertIn("Password Set Change password", text) + self.assertIn("Password: Set Change password", text) self.assertNotIn("No password set", text) self.assertEqual(page.xpath("//a[normalize-space()='Change password']/@href"), [reverse("admin:user_update_password", args=[self.alice.pk])]) @@ -57,15 +57,35 @@ class UserPagesTests(TestCase): self.assertNotIn("log into this admin site", page) self.assertIn("Max memory (MiB)", page) self.assertIn("Max disk size (GiB)", page) - self.assertIn("-1: no limit; 0: none allowed", page) + self.assertIn("-1 means no limit, 0 none allowed", page) + + def test_a_refused_choice_is_said_beside_its_field(self): + url = reverse("admin:user_update", args=[self.alice.pk]) + response = self.client.post(url, {"username": "up-alice", "is_active": "on", "groups": "99999", "user_permissions": "99999", + "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 1}) + page = html.fromstring(response.content) + self.assertEqual(len(page.xpath("//fieldset//div[contains(@class, 'invalid-feedback')]")), 2) + + def test_the_folded_quota_says_what_it_allows(self): + UserAttributes.objects.filter(user=self.alice).update(max_instances=-1, max_cpus=1, max_memory=2048, max_disk_size=-1) + page = html.fromstring(self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content) + (summary,) = [s for s in page.xpath("//summary") if "Quota" in s.text_content()] + self.assertEqual(" ".join(summary.text_content().split()), "Quota any number of VMs · 1 vCPU · 2048 MiB · any disk size") def test_a_missing_users_profile_is_not_found(self): self.assertEqual(self.client.get(reverse("accounts:account", args=[99999])).status_code, 404) def test_a_user_who_never_logged_in_says_never(self): page = html.fromstring(self.client.get(reverse("admin:user_list")).content) - (row,) = [r for r in page.xpath("//tbody/tr") if r.xpath("td")[0].text_content().strip() == "up-alice"] - self.assertEqual(row.xpath("td")[2].text_content().strip(), "never") + (row,) = page.xpath("//div[contains(@class, 'list-group-item')][.//a[normalize-space()='up-alice']]") + self.assertIn("Last login: never", " ".join(row.text_content().split())) + + def test_the_search_finds_a_user_by_role_and_status(self): + self.alice.is_active = False + self.alice.save() + page = html.fromstring(self.client.get(reverse("admin:user_list")).content) + (row,) = page.xpath("//div[contains(@class, 'list-group-item')][.//a[normalize-space()='up-alice']]") + self.assertIn("User Blocked", row.get("data-search")) def test_a_password_set_by_an_administrator_can_be_one_to_change(self): url = reverse("admin:user_update_password", args=[self.alice.pk]) diff --git a/admin/views.py b/admin/views.py index 51e8ef41..9c223209 100644 --- a/admin/views.py +++ b/admin/views.py @@ -31,7 +31,7 @@ def group_snapshot(group): @superuser_only def group_list(request): - groups = Group.objects.all() + groups = Group.objects.annotate(members=Count("user", distinct=True), perms=Count("permissions", distinct=True)).order_by("name") return render( request, "admin/group_list.html", @@ -51,7 +51,7 @@ def group_create(request): return render( request, - "common/form.html", + "admin/group_form.html", { "form": form, "title": _("Create Group"), @@ -74,9 +74,10 @@ def group_update(request, pk): return render( request, - "common/form.html", + "admin/group_form.html", { "form": form, + "group": group, "title": _("Update Group"), }, ) @@ -106,7 +107,7 @@ OWN_ACCOUNT = _("You cannot block, delete or demote your own account") def user_list(request): # 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)) + users = User.objects.annotate(api_tokens=Count("auth_token_set", filter=live)).prefetch_related("groups") return render( request, "admin/user_list.html", diff --git a/static/css/webvirtcloud.css b/static/css/webvirtcloud.css index 5ef83b4b..e42b2fbd 100644 --- a/static/css/webvirtcloud.css +++ b/static/css/webvirtcloud.css @@ -132,3 +132,8 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after { .boot-sequence > label:has(> input:checked) .boot-rank::before { content: counter(boot); } + +/* a log message keeps a readable width when its table scrolls on a phone */ +.log-message { + min-width: 16rem; +} diff --git a/static/icon_cache/play_None_None_None.svg b/static/icon_cache/play_None_None_None.svg new file mode 100644 index 00000000..447bfbca --- /dev/null +++ b/static/icon_cache/play_None_None_None.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/icon_cache/slash-circle_None_None_None.svg b/static/icon_cache/slash-circle_None_None_None.svg new file mode 100644 index 00000000..bb43109a --- /dev/null +++ b/static/icon_cache/slash-circle_None_None_None.svg @@ -0,0 +1,11 @@ + + + + + + + + + + +