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 #} +
+{% 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 %} -| {% trans "Group Name" %} | -{% trans "Actions" %} | -
|---|---|
| - {{ group.name }} - | -- - | -
| # | +{% 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 %} | + +{% 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:"" }} | -
{% 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 %} +| {% 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" }} | -- - | -
{% trans "For each limit: -1 means no limit, 0 none allowed." %}
+| {% 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 %}
-
- {% else %}
-
- {% endif %}
- {% bs_icon 'x-circle-fill' %}
-
- |
-