mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 03:36:48 +00:00
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.
This commit is contained in:
parent
db3c80b620
commit
3c6de221ca
15 changed files with 477 additions and 260 deletions
|
|
@ -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"), [])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
50
admin/templates/admin/group_form.html
Normal file
50
admin/templates/admin/group_form.html
Normal file
|
|
@ -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 #}
|
||||
<form id="create-update" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form type="non_fields" %}
|
||||
{% bootstrap_field form.name %}
|
||||
|
||||
<h4 class="h5 mb-1">{% trans "What can its members do?" %}</h4>
|
||||
<p class="small text-body-secondary mb-2">{% trans "Each member has these permissions besides their own." %}</p>
|
||||
<div class="border-top mb-4">
|
||||
{% for box, help in form.permission_rows %}
|
||||
<div class="form-check form-switch border-bottom py-2 ps-5">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="permissions"
|
||||
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ box.id_for_label }}">
|
||||
<span class="d-block">{{ box.data.value.instance.name }}</span>
|
||||
<span class="small text-body-secondary">{{ help }}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for error in form.permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
|
||||
<fieldset class="mb-3">
|
||||
<legend class="fs-6 fw-semibold">{% trans "Members" %}</legend>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for box in form.users %}
|
||||
<label class="border rounded px-2 py-1 text-break">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="users" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
{{ box.choice_label }}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for error in form.users.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-3">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'admin:group_list' %}">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
|
@ -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 %}
|
||||
<a href="{% url 'admin:group_create' %}" class="btn btn-primary">
|
||||
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New group" %}
|
||||
</a>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<a href="{% url 'admin:group_create' %}" class="btn btn-success mt-1 float-end" title="{% trans "Create Group" %}">
|
||||
{% bs_icon 'plus-circle-fill' %}
|
||||
</a>
|
||||
{% include 'search_block.html' %}
|
||||
<h1 class="mb-3">{% trans "Groups" %}</h1>
|
||||
{% if not groups %}
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle' %} {% trans "There are no groups yet." %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if not groups %}
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle' %} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any groups" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Groups" %} <span class="badge text-bg-light border">{{ groups|length }}</span></span>
|
||||
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search groups" %}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-lg-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Group Name" %}</th>
|
||||
<th scope="col">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for group in groups %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href=""><strong>{{ group.name }}</strong></a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="float-end btn-group">
|
||||
<a class="btn btn-primary" href="{% url 'admin:group_update' group.id %}" title="{%trans "Edit" %}">
|
||||
{% bs_icon 'pencil' %}
|
||||
</a>
|
||||
<a class="btn btn-danger" href="{% url 'admin:group_delete' group.id %}" title="{%trans "Delete" %}">
|
||||
{% bs_icon 'x' %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="list-group list-group-flush searchable">
|
||||
{% for group in groups %}
|
||||
<div class="list-group-item py-3" data-search="{{ group.name }}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{% url 'admin:group_update' group.id %}" class="fw-semibold text-break">{{ group.name }}</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 small text-body-secondary">
|
||||
{% blocktrans count count=group.members %}{{ count }} member{% plural %}{{ count }} members{% endblocktrans %}
|
||||
· {% blocktrans count count=group.perms %}{{ count }} permission{% plural %}{{ count }} permissions{% endblocktrans %}
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'admin:group_update' group.id %}" class="btn btn-sm btn-outline-secondary">{% trans "Edit" %}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=group.name %}More actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'admin:group_delete' group.id %}">{% bs_icon 'trash' %} {% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div role="status">
|
||||
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No group matches the search." %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'js/table_filter.js' %}"></script>
|
||||
{% if groups %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -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 #}
|
||||
<form method="get" class="float-end d-flex flex-wrap gap-2 align-items-center" role="search">
|
||||
<input name="q" value="{{ q }}" class="form-control w-auto" type="search" placeholder="{% trans 'Search' %}"
|
||||
aria-label="{% trans 'Search the logs' %}">
|
||||
<div class="form-check mb-0">
|
||||
<input id="logs-failed" class="form-check-input" type="checkbox" name="failed" value="1"{% if failed %} checked{% endif %}>
|
||||
<label class="form-check-label" for="logs-failed">{% trans "Failed only" %}</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Search" %}</button>
|
||||
<a class="btn btn-outline-primary" href="{% url 'admin:logs_export' %}{% if query %}?{{ query }}{% endif %}"
|
||||
title="{% trans 'Download these rows as an Excel file' %}">{% bs_icon 'download' %} {% trans "Excel" %}</a>
|
||||
</form>
|
||||
<a class="btn btn-outline-primary" href="{% url 'admin:logs_export' %}{% if query %}?{{ query }}{% endif %}"
|
||||
title="{% trans 'Download these rows as an Excel file' %}"><span aria-hidden="true">{% bs_icon 'download' %}</span> {% trans "Excel" %}</a>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
{% if not logs %}
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle'%}
|
||||
{% if q or failed %}{% trans "No log rows match." %}{% else %}{% trans "There are no log rows yet." %}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle">
|
||||
<thead>
|
||||
<div class="card mb-3">
|
||||
{# the search runs on the server over every row; the download takes the same rows #}
|
||||
<form method="get" class="card-header d-flex flex-wrap align-items-center gap-2" role="search">
|
||||
<span class="me-auto">{% trans "Logs" %} <span class="badge text-bg-light border">{{ logs.paginator.count }}</span></span>
|
||||
<input name="q" value="{{ q }}" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans 'Search' %}"
|
||||
aria-label="{% trans 'Search the logs' %}">
|
||||
<div class="form-check mb-0">
|
||||
<input id="logs-failed" class="form-check-input" type="checkbox" name="failed" value="1"{% if failed %} checked{% endif %}>
|
||||
<label class="form-check-label small" for="logs-failed">{% trans "Failed only" %}</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans "Search" %}</button>
|
||||
</form>
|
||||
{% if not logs %}
|
||||
<div class="card-body text-center text-body-secondary">
|
||||
{% if q or failed %}{% trans "No log rows match." %}{% else %}{% trans "There are no log rows yet." %}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive position-relative">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">{% blocktrans %}Date ({{ time_zone }}){% endblocktrans %}</th>
|
||||
<th scope="col">{% trans "User" %}</th>
|
||||
<th scope="col">{% trans "Host" %}</th>
|
||||
<th scope="col">{% trans "Instance" %}</th>
|
||||
<th scope="col">{% trans "Message" %}</th>
|
||||
<th scope="col">{% trans "Result" %}</th>
|
||||
<th scope="col">{% trans "IP" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">{% blocktrans %}Date ({{ time_zone }}){% endblocktrans %}</th>
|
||||
<th scope="col">{% trans "User" %}</th>
|
||||
<th scope="col">{% trans "Host" %}</th>
|
||||
<th scope="col">{% trans "Instance" %}</th>
|
||||
<th scope="col">{% trans "Message" %}</th>
|
||||
<th scope="col">{% trans "Result" %}</th>
|
||||
<th scope="col">{% trans "IP" %}</th>
|
||||
<td class="small text-body-secondary">{{ log.id }}</td>
|
||||
<td class="small text-nowrap">{{ log.date|date:"Y-m-d H:i:s" }}</td>
|
||||
<td>{{ log.user }}</td>
|
||||
<td>{{ log.host }}</td>
|
||||
<td>{% if log.vm_pk %}<a href="{% url 'instances:instance' log.vm_pk %}">{{ log.instance }}</a>{% else %}{{ log.instance }}{% endif %}</td>
|
||||
<td class="log-message">{{ log.message }}</td>
|
||||
<td>{% if log.success is None %}<span class="small text-body-secondary">{% trans "Unknown" %}</span>{% elif log.success %}<span class="badge text-bg-light border">{% trans "Done" %}</span>{% else %}<span class="badge text-bg-danger">{% trans "Failed" %}</span>{% endif %}</td>
|
||||
<td class="small font-monospace">{{ log.ip|default:"" }}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr{% if log.success is False %} class="table-danger"{% endif %}>
|
||||
<td>{{ log.id }}</td>
|
||||
<td class="text-nowrap">{{ log.date|date:"Y-m-d H:i:s" }}</td>
|
||||
<td>{{ log.user }}</td>
|
||||
<td>{{ log.host }}</td>
|
||||
<td>{% if log.vm_pk %}<a href="{% url 'instances:instance' log.vm_pk %}">{{ log.instance }}</a>{% else %}{{ log.instance }}{% endif %}</td>
|
||||
<td>{{ log.message }}</td>
|
||||
<td>{% if log.success is None %}{% trans "Unknown" %}{% elif log.success %}{% trans "Done" %}{% else %}{% trans "Failed" %}{% endif %}</td>
|
||||
<td>{{ log.ip|default:"" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% bootstrap_pagination logs extra=query %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if logs %}{% bootstrap_pagination logs extra=query %}{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
{% include 'search_block.html' %}
|
||||
<h1 class="mb-3">{% trans "Removed VMs" %}</h1>
|
||||
<p class="text-muted">{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}</p>
|
||||
<p class="text-body-secondary">{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}</p>
|
||||
{% if not tombstones %}
|
||||
<div class="alert alert-info shadow-sm">
|
||||
{% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if not tombstones %}
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-info shadow-sm">
|
||||
{% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Removed VMs" %} <span class="badge text-bg-light border">{{ tombstones|length }}</span></span>
|
||||
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search removed VMs" %}">
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-lg-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Name" %}</th>
|
||||
<th scope="col">{% trans "UUID" %}</th>
|
||||
<th scope="col">{% trans "Host" %}</th>
|
||||
<th scope="col">{% trans "Owners" %}</th>
|
||||
<th scope="col">{% trans "Removed" %}</th>
|
||||
<th scope="col">{% trans "Expires" %}</th>
|
||||
<th scope="col">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for tombstone in tombstones %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ tombstone.name }}</strong>
|
||||
{% if tombstone.is_template %}<span class="badge bg-secondary">{% trans "Template" %}</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ tombstone.uuid }}</td>
|
||||
<td>{{ tombstone.host }}</td>
|
||||
<td>{{ tombstone.owner_names|join:", " }}</td>
|
||||
<td>{{ tombstone.removed|date:"Y-m-d H:i" }}</td>
|
||||
<td>{{ tombstone.expires|date:"Y-m-d H:i" }}</td>
|
||||
<td>
|
||||
<div class="float-end btn-group">
|
||||
<a class="btn btn-danger" href="{% url 'admin:tombstone_delete' tombstone.id %}" title="{% trans "Delete" %}">
|
||||
{% bs_icon 'x' %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="list-group list-group-flush searchable">
|
||||
{% for tombstone in tombstones %}
|
||||
<div class="list-group-item py-3" data-search="{{ tombstone.name }} {{ tombstone.uuid }} {{ tombstone.host }} {{ tombstone.owner_names|join:' ' }}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<span class="fw-semibold text-break">{{ tombstone.name }}</span>
|
||||
{% if tombstone.is_template %}<span class="badge text-bg-light border">{% trans "Template" %}</span>{% endif %}
|
||||
<div class="font-monospace small text-body-secondary text-break mt-1">{{ tombstone.uuid }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 small">
|
||||
<div>
|
||||
{% trans "Left" %} <span class="fw-semibold">{{ tombstone.host }}</span>
|
||||
<span class="text-body-secondary">· {% trans "Owners" %}:</span> {{ tombstone.owner_names|join:", "|default:"–" }}
|
||||
</div>
|
||||
<div class="text-body-secondary mt-1">
|
||||
{% trans "Removed" %} <span class="text-nowrap">{{ tombstone.removed|date:"Y-m-d H:i" }}</span>
|
||||
· {% trans "expires" %} <span class="text-nowrap">{{ tombstone.expires|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end">
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:tombstone_delete' tombstone.id %}">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div role="status">
|
||||
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No removed VM matches the search." %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'js/table_filter.js' %}"></script>
|
||||
{% if tombstones %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -8,29 +8,134 @@
|
|||
{% block page_heading %}{{ title }}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card col-sm-10 offset-sm-1">
|
||||
<div class="card-body">
|
||||
<form id="create-update" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form user_form layout='horizontal' %}
|
||||
{% if edited_user %}
|
||||
<div class="row mb-3">
|
||||
<span class="col-sm-2 col-form-label">{% trans "Password" %}</span>
|
||||
<div class="col-sm-10 col-form-label">
|
||||
{% if edited_user.has_usable_password %}{% trans "Set" %}{% else %}{% trans "Not set" %}{% endif %}
|
||||
<a class="ms-2" href="{% url 'admin:user_update_password' edited_user.id %}">{% trans "Change password" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% bootstrap_form attributes_form layout='horizontal' %}
|
||||
</form>
|
||||
<div class="float-end">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">
|
||||
{% bs_icon 'x-square-fill' %} {% trans "Cancel" %}</a>
|
||||
<button type="submit" form="create-update" class="btn btn-success">
|
||||
{% bs_icon 'check2' %} {% trans "Save" %}
|
||||
</button>
|
||||
{# what the user may do comes first; who they are and their quota fold away #}
|
||||
{% if edited_user %}
|
||||
<div class="border-bottom pb-3 mb-3">
|
||||
<span class="fs-5 fw-semibold text-break">{{ edited_user.username }}</span>
|
||||
{% if edited_user.email %}<span class="text-body-secondary ms-2 text-break">{{ edited_user.email }}</span>{% endif %}
|
||||
<div class="small text-body-secondary">{% trans "Last login" %} {{ edited_user.last_login|date:"Y-m-d H:i"|default:_("never") }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="create-update" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors user_form type="non_fields" %}
|
||||
|
||||
<div class="form-check form-switch mb-4">
|
||||
<input id="{{ user_form.is_active.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="is_active"
|
||||
aria-describedby="active-help"{% if user_form.is_active.value %} checked{% endif %}>
|
||||
<label class="form-check-label fw-semibold" for="{{ user_form.is_active.id_for_label }}">{% trans "Active: may sign in" %}</label>
|
||||
<div id="active-help" class="small text-body-secondary">{% trans "Turn off to block the user while keeping the account." %}</div>
|
||||
</div>
|
||||
|
||||
<h4 class="h5 mb-1">
|
||||
{% if edited_user %}{% blocktrans with name=edited_user.username %}What can {{ name }} do?{% endblocktrans %}{% else %}{% trans "What can the user do?" %}{% endif %}
|
||||
</h4>
|
||||
<p class="small text-body-secondary mb-3">{% trans "The permissions of the user's groups apply too." %}</p>
|
||||
|
||||
<div class="row row-cols-1 row-cols-md-2 g-2 mb-3">
|
||||
<div class="col">
|
||||
<label class="border rounded p-3 h-100 d-flex gap-2">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" name="is_staff"{% if user_form.is_staff.value %} checked{% endif %}>
|
||||
<span><span class="d-block fw-semibold">{% trans "Template editor" %}</span>
|
||||
<span class="small text-body-secondary">{% trans "May change and delete the template VMs given to them." %}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="border rounded p-3 h-100 d-flex gap-2">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="checkbox" name="is_superuser"{% if user_form.is_superuser.value %} checked{% endif %}>
|
||||
<span><span class="d-block fw-semibold">{% trans "Superuser" %}</span>
|
||||
<span class="small text-body-secondary">{% trans "Has every permission and manages the panel, whatever is chosen below." %}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
<fieldset class="mb-3">
|
||||
<legend class="fs-6 fw-semibold">{% trans "Groups" %}</legend>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for box in user_form.groups %}
|
||||
<label class="border rounded px-2 py-1 text-break">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="groups" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
{{ box.choice_label }}
|
||||
</label>
|
||||
{% empty %}
|
||||
<span class="small text-body-secondary">{% trans "There are no groups yet." %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for error in user_form.groups.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="mb-3">
|
||||
<legend class="fs-6 fw-semibold">{% trans "Own permissions" %}</legend>
|
||||
<p class="small text-body-secondary mb-2">{% trans "Off grants nothing here; it takes away nothing a group grants." %}</p>
|
||||
<div class="border-top">
|
||||
{% for box, help in user_form.permission_rows %}
|
||||
<div class="form-check form-switch border-bottom py-2 ps-5">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="user_permissions"
|
||||
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ box.id_for_label }}">
|
||||
<span class="d-block">{{ box.data.value.instance.name }}</span>
|
||||
<span class="small text-body-secondary">{{ help }}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for error in user_form.user_permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</fieldset>
|
||||
|
||||
<details class="border-bottom py-3"{% if not edited_user or user_form.identity_has_errors %} open{% endif %}>
|
||||
<summary>
|
||||
<span class="fw-semibold">{% trans "Name and password" %}</span>
|
||||
{% if edited_user %}
|
||||
<span class="d-block small text-body-secondary ms-3 text-break">
|
||||
{{ 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 %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</summary>
|
||||
<div class="pt-3">
|
||||
{% bootstrap_field user_form.username %}
|
||||
{% if user_form.password %}{% bootstrap_field user_form.password %}{% endif %}
|
||||
<div class="row g-2">
|
||||
<div class="col-sm-6">{% bootstrap_field user_form.first_name %}</div>
|
||||
<div class="col-sm-6">{% bootstrap_field user_form.last_name %}</div>
|
||||
</div>
|
||||
{% bootstrap_field user_form.email %}
|
||||
{% if edited_user %}
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<span>{% trans "Password" %}: {% if edited_user.has_usable_password %}{% trans "Set" %}{% else %}{% trans "Not set" %}{% endif %}</span>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:user_update_password' edited_user.id %}">{% trans "Change password" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% with quota=attributes_form.instance %}
|
||||
<details class="border-bottom py-3"{% if attributes_form.errors %} open{% endif %}>
|
||||
<summary>
|
||||
<span class="fw-semibold">{% trans "Quota" %}</span>
|
||||
<span class="d-block small text-body-secondary ms-3">
|
||||
{% 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 %}
|
||||
</span>
|
||||
</summary>
|
||||
<div class="pt-3">
|
||||
<p class="small text-body-secondary">{% trans "For each limit: -1 means no limit, 0 none allowed." %}</p>
|
||||
<div class="row row-cols-1 row-cols-sm-2 g-2">
|
||||
<div class="col">{% bootstrap_field attributes_form.max_instances show_help=False %}</div>
|
||||
<div class="col">{% bootstrap_field attributes_form.max_cpus show_help=False %}</div>
|
||||
<div class="col">{% bootstrap_field attributes_form.max_memory show_help=False %}</div>
|
||||
<div class="col">{% bootstrap_field attributes_form.max_disk_size show_help=False %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
{% endwith %}
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mt-3">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'admin:user_list' %}">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -9,78 +9,84 @@
|
|||
{% block page_heading %}{{ title }}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
<a href="{% url 'admin:user_create' %}" class="btn btn-success mt-1 float-end" title="{% trans "Create User" %}">
|
||||
{% bs_icon 'plus-circle-fill' %}
|
||||
<a href="{% url 'admin:user_create' %}" class="btn btn-primary">
|
||||
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New user" %}
|
||||
</a>
|
||||
{% include 'search_block.html' %}
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{% if not users %}
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle' %} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any user" %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Users" %} <span class="badge text-bg-light border">{{ users|length }}</span></span>
|
||||
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search users" %}">
|
||||
</div>
|
||||
<div class="list-group list-group-flush searchable">
|
||||
{% for user in users %}
|
||||
{% has_perm user 'instances.clone_instances' as can_clone %}
|
||||
<div class="list-group-item py-3" data-search="{{ user.username }} {{ user.email }}{% for group in user.groups.all %} {{ group.name }}{% endfor %} {% 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 %}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{% url 'accounts:account' user.id %}" class="fw-semibold text-break">{{ user.username }}</a>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-1 small text-body-secondary">
|
||||
{% if user.is_superuser %}
|
||||
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
|
||||
{% elif user.is_staff %}
|
||||
<span class="badge text-bg-light border">{% trans "Template editor" %}</span>
|
||||
{% else %}
|
||||
<span>{% trans "User" %}</span>
|
||||
{% endif %}
|
||||
{% if user.is_active %}
|
||||
<span>{% trans "Active" %}</span>
|
||||
{% else %}
|
||||
<span class="badge text-bg-danger"><span aria-hidden="true">{% bs_icon 'slash-circle' %}</span> {% trans "Blocked" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 small">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
{% for group in user.groups.all %}<span class="badge text-bg-light border mw-100 text-wrap text-break">{{ group.name }}</span>{% endfor %}
|
||||
{# a superuser has every permission: only a granted clone permission is news #}
|
||||
{% if can_clone and not user.is_superuser %}<span>{% trans "Can clone" %}</span>{% endif %}
|
||||
{% if user.api_tokens %}
|
||||
<a href="{% url 'accounts:account' user.id %}#apitokens">{% blocktrans count count=user.api_tokens %}{{ count }} API token{% plural %}{{ count }} API tokens{% endblocktrans %}</a>
|
||||
{% endif %}
|
||||
{% if not user.groups.all and not user.api_tokens %}{% if user.is_superuser or not can_clone %}
|
||||
<span class="text-body-secondary">{% trans "No groups or API tokens" %}</span>
|
||||
{% endif %}{% endif %}
|
||||
</div>
|
||||
<div class="text-body-secondary mt-1">
|
||||
{% trans "Last login" %}: <span class="text-nowrap">{{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'admin:user_update' user.id %}" class="btn btn-sm btn-outline-secondary">{% trans "Edit" %}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=user.username %}More actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||
<li><a class="dropdown-item" href="{% url 'accounts:account' user.id %}">{% bs_icon 'person' %} {% trans "View profile" %}</a></li>
|
||||
{# an administrator does not block or delete their own account #}
|
||||
<li><form method="post" action="{% if user.is_active %}{% url 'admin:user_block' user.id %}{% else %}{% url 'admin:user_unblock' user.id %}{% endif %}">{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item"{% if user == request.user %} disabled{% endif %}>
|
||||
{% if user.is_active %}{% bs_icon 'slash-circle' %} {% trans "Block" %}{% else %}{% bs_icon 'play' %} {% trans "Unblock" %}{% endif %}
|
||||
</button>
|
||||
</form></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>{% if user == request.user %}
|
||||
<button type="button" class="dropdown-item text-danger" disabled>{% bs_icon 'trash' %} {% trans "Delete" %}</button>
|
||||
{% else %}
|
||||
<a class="dropdown-item text-danger" href="{% url 'admin:user_delete' user.id %}">{% bs_icon 'trash' %} {% trans "Delete" %}</a>
|
||||
{% endif %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-lg-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Username" %}</th>
|
||||
<th scope="col">{% trans "Status" %}</th>
|
||||
<th scope="col">{% trans "Last Login" %}</th>
|
||||
<th scope="col">{% trans "Template editor" %}</th>
|
||||
<th scope="col">{% trans "Superuser" %}</th>
|
||||
<th scope="col">{% trans "Can Clone" %}</th>
|
||||
<th scope="col">{% trans "API tokens" %}</th>
|
||||
<th scope="col">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for user in users %}
|
||||
{% has_perm user 'instances.clone_instances' as can_clone %}
|
||||
<tr class="{% if not user.is_active %}danger{% endif %}">
|
||||
<td>
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
{% trans "Active" %}
|
||||
{% else %}
|
||||
{% trans "Blocked" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</td>
|
||||
<td>{% if user.is_staff %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>{% if user.is_superuser %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>{% if can_clone %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>{% if user.api_tokens %}<a href="{% url 'accounts:account' user.id %}#apitokens">{{ user.api_tokens }}</a>{% endif %}</td>
|
||||
<td>
|
||||
<div class="float-end btn-group">
|
||||
<a class="btn btn-success" title="{%trans "View Profile" %}" href="{% url 'accounts:account' user.id %}">{% bs_icon 'eye-fill' %}</a>
|
||||
<a class="btn btn-primary" title="{%trans "Edit" %}" href="{% url 'admin:user_update' user.id %}">{% bs_icon 'pencil-fill' %}</a>
|
||||
{% if user.is_active %}
|
||||
<form method="post" action="{% url 'admin:user_block' user.id %}">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-warning rounded-0" title="{%trans "Block" %}">{% bs_icon 'stop-fill' %}</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="post" action="{% url 'admin:user_unblock' user.id %}">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-success rounded-0" title="{%trans "Unblock" %}">{% bs_icon 'play-fill' %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<a class="btn btn-danger" title="{%trans "Delete" %}" href="{% url 'admin:user_delete' user.id %}">{% bs_icon 'x-circle-fill' %}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div role="status">
|
||||
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No user matches the search." %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -38,8 +38,7 @@ class TombstoneTests(TestCase):
|
|||
self.assertContains(response, "ts-vm")
|
||||
self.assertContains(response, UUID)
|
||||
self.assertContains(response, "ts-owner")
|
||||
self.assertContains(response, "<td>ts-host</td>", html=True)
|
||||
self.assertContains(response, '<th scope="col">Host</th>', html=True)
|
||||
self.assertContains(response, '<span class="fw-semibold">ts-host</span>', html=True)
|
||||
|
||||
def test_only_superusers_see_or_remove_them(self):
|
||||
self.client.force_login(self.owner)
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
8
static/icon_cache/play_None_None_None.svg
Normal file
8
static/icon_cache/play_None_None_None.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-play" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M10.804 8 5 4.633v6.734zm.792-.696a.802.802 0 0 1 0 1.392l-6.363 3.692C4.713 12.69 4 12.345 4 11.692V4.308c0-.653.713-.998 1.233-.696z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 309 B |
11
static/icon_cache/slash-circle_None_None_None.svg
Normal file
11
static/icon_cache/slash-circle_None_None_None.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" ?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-slash-circle" viewBox="0 0 16 16">
|
||||
|
||||
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
|
||||
|
||||
<path d="M11.354 4.646a.5.5 0 0 0-.708 0l-6 6a.5.5 0 0 0 .708.708l6-6a.5.5 0 0 0 0-.708z"/>
|
||||
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 348 B |
Loading…
Add table
Add a link
Reference in a new issue