From 3c6de221ca94c36c87c67514edc8c48429e6a0e8 Mon Sep 17 00:00:00 2001 From: catborise Date: Sat, 31 Oct 2026 09:55:36 +0300 Subject: [PATCH] feat(admin): the admin lists as a people directory, the user form by what it allows The Users, Groups and Removed VMs pages were bare tables: rows of coloured icon buttons without words, five columns of small checks for a user's role and permissions, dates broken over three lines and a lone "+" for a new entry. The owner chose a directory layout from three rendered options; it follows the host pages' cards: - Each list is a card with its count and search in the header and one list-group item per entry, which stacks on a phone without scrolling. - A user shows their role as a badge (Superuser, Template editor) or "User", Blocked as a red badge, their groups, a granted clone permission and live API tokens, and the last login on one line; role and status are searchable. Edit is the visible action, the menu holds View profile, Block or Unblock and Delete; Block and Delete are disabled on one's own account, which the server refuses anyway. - A group shows its member and permission counts; its name opens it. The page's title said "Users". - A removed VM shows its UUID, the host it left, its owners and when the record was made and expires. - Logs keep their table in a card, the server search in its header, the count of matching rows, Done and Failed as badges, and a message column wide enough to read on a phone. The user form was one long column of horizontal fields. The owner rejected three boxed layouts and chose one led by what the user may do: the Active switch, Template editor and Superuser as two cards, groups as chips, the user's own permissions as switches each saying what it allows, then the name and password and the quota folded into details whose summaries tell their values (-1 as "any"); they open on a new user and on an error inside them. A refused group or permission is said beside its field. The group form follows it: the name, what its members may do, its members as chips. Cancel goes back to the list instead of the browser history. --- accounts/tests/test_api_tokens.py | 14 +- admin/forms.py | 24 +++ admin/templates/admin/group_form.html | 50 ++++++ admin/templates/admin/group_list.html | 96 +++++------ admin/templates/admin/logs.html | 100 ++++++------ admin/templates/admin/tombstone_list.html | 96 +++++------ admin/templates/admin/user_form.html | 153 +++++++++++++++--- admin/templates/admin/user_list.html | 138 ++++++++-------- admin/tests/test_logs_page.py | 2 +- admin/tests/test_tombstones.py | 3 +- admin/tests/test_user_pages.py | 28 +++- admin/views.py | 9 +- static/css/webvirtcloud.css | 5 + static/icon_cache/play_None_None_None.svg | 8 + .../slash-circle_None_None_None.svg | 11 ++ 15 files changed, 477 insertions(+), 260 deletions(-) create mode 100644 admin/templates/admin/group_form.html create mode 100644 static/icon_cache/play_None_None_None.svg create mode 100644 static/icon_cache/slash-circle_None_None_None.svg 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 @@ + + + + + + + + + + +