1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-08 11:16:47 +00:00

style(admin): the user form asks who, what role, what quota, then what more

The create and edit pages of a user had one form for both, ordered for
editing: permissions first, the name and password folded below them and
the quota folded and closed at the end; a new user's name came last.

Both pages now go Account (name, password, may sign in), Role, Quota,
then Groups and permissions, each section with what it is for beside its
fields. Template editor and Superuser become one Role choice with User:
choosing Superuser hides the quota and permissions, which a superuser
does not need (no quota check applies, every permission is theirs); it
keeps the is_staff it had. The quota is always open: each limit takes a
number from 0, or No limit, which is kept as -1 as before; memory is
entered in GiB and kept in MiB, shown exactly so saving a user again
changes nothing. The button says Create user or Save changes.
This commit is contained in:
catborise 2026-11-04 22:25:43 +03:00
parent c9fe077fe8
commit 6523fd0955
13 changed files with 988 additions and 865 deletions

View file

@ -42,7 +42,7 @@ class ChangePasswordPermissionTestCase(TestCase):
def test_create_form_ticks_it_and_the_admin_choice_wins(self):
self.assertIn(permission(), UserCreateForm().fields["user_permissions"].initial)
form = UserCreateForm(data={"username": "unticked", "password": "pw", "is_active": True})
form = UserCreateForm(data={"username": "unticked", "password": "pw", "is_active": True, "role": "user"})
self.assertTrue(form.is_valid(), form.errors)
self.assertFalse(can_change(form.save()))

View file

@ -1,3 +1,5 @@
from decimal import ROUND_HALF_UP, Decimal
from django import forms
from django.conf import settings
from django.contrib.auth import password_validation
@ -64,6 +66,14 @@ class GroupForm(forms.ModelForm):
class UserForm(UniqueEmailMixin, forms.ModelForm):
# one choice for is_staff and is_superuser: a superuser keeps the
# is_staff it had (it adds nothing to every permission)
role = forms.ChoiceField(
widget=forms.RadioSelect,
choices=[("user", _("User")), ("template_editor", _("Template editor")), ("superuser", _("Superuser"))],
initial="user",
)
user_permissions = forms.ModelMultipleChoiceField(
widget=forms.CheckboxSelectMultiple,
queryset=Permission.objects.filter(content_type__model="permissionset"),
@ -87,12 +97,24 @@ class UserForm(UniqueEmailMixin, forms.ModelForm):
"last_name",
"email",
"user_permissions",
"is_staff",
"is_active",
"is_superuser",
]
labels = {"is_staff": _("Template editor")}
help_texts = {"is_staff": _("May change and delete the template VMs given to them")}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.is_superuser:
self.initial["role"] = "superuser"
elif self.instance.is_staff:
self.initial["role"] = "template_editor"
def clean(self):
cleaned = super().clean()
role = cleaned.get("role")
if role:
cleaned["is_superuser"] = self.instance.is_superuser = role == "superuser"
if role != "superuser":
self.instance.is_staff = role == "template_editor"
return cleaned
def permission_rows(self):
return permission_rows(self["user_permissions"])
@ -152,16 +174,55 @@ class UserCreateForm(UserForm):
"last_name",
"email",
"user_permissions",
"is_staff",
"is_active",
"is_superuser",
]
# the quota's limits, with the unit each is entered in (memory in GiB, kept in MiB)
QUOTA_UNITS = {"max_instances": "", "max_cpus": "", "max_memory": "GiB", "max_disk_size": "GiB"}
class UserAttributesForm(forms.ModelForm):
"""The quota: each limit a number from 0 (none allowed) or No limit,
which is kept as -1; memory is entered in GiB and kept in MiB."""
max_instances = forms.IntegerField(label=_("VMs"), min_value=0, required=False)
max_cpus = forms.IntegerField(label=_("vCPUs"), min_value=0, required=False)
max_memory = forms.DecimalField(label=_("Memory"), min_value=0, required=False)
max_disk_size = forms.IntegerField(label=_("Disk"), min_value=0, required=False)
class Meta:
model = UserAttributes
exclude = ["user", "can_clone_instances", "must_change_password"]
fields = list(QUOTA_UNITS)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name in QUOTA_UNITS:
limit = getattr(self.instance, name) # a new user's: the model's default
self.fields[f"{name}_unlimited"] = forms.BooleanField(label=_("No limit"), required=False, initial=limit == -1)
if limit == -1:
self.initial[name] = None
elif name == "max_memory":
# exact: a MiB count over 1024 always ends, so saving it again changes nothing
self.initial[name] = format(Decimal(limit) / 1024, "f")
else:
self.initial[name] = limit
def clean(self):
cleaned = super().clean()
for name in QUOTA_UNITS:
if cleaned.get(f"{name}_unlimited"):
cleaned[name] = -1
elif cleaned.get(name) is None:
if name not in self.errors:
self.add_error(name, _("Enter a limit or choose No limit."))
elif name == "max_memory":
cleaned[name] = int((cleaned[name] * 1024).to_integral_value(ROUND_HALF_UP))
return cleaned
def quota_rows(self):
"""Each limit's field, its No limit box and its unit, for the form page."""
return [(self[name], self[f"{name}_unlimited"], unit) for name, unit in QUOTA_UNITS.items()]
class AdminPasswordForm(AdminPasswordChangeForm):

View file

@ -1,141 +1,177 @@
{% extends "base.html" %}
{% load django_bootstrap5 %}
{% load bootstrap_icons %}
{% load i18n %}
{% load static %}
{% block title %}{{ title }}{% endblock %}
{% block page_heading %}{{ title }}{% endblock page_heading %}
{% block content %}
{# 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 %}
{% block style %}
<style>
/* each section: what it is about on the left, its fields on the right */
.user-form-section { border-top: 1px solid var(--bs-border-color); padding: 1.5rem 0; }
.user-form-section:first-of-type { border-top: 0; padding-top: .5rem; }
.user-form-fields { max-width: 52rem; }
.user-form-role:has(input:checked) { border-color: var(--bs-primary) !important; box-shadow: inset 0 0 0 1px var(--bs-primary); }
</style>
{% endblock style %}
{% block content %}
{# who the user is, what they manage, what they may use, then what more they may do #}
<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>
<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>
<section class="user-form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Account" %}</h2>
{% if edited_user %}
<p class="small text-body-secondary mb-0">{% trans "Last login" %}: {{ edited_user.last_login|date:"Y-m-d H:i"|default:_("never") }}</p>
{% else %}
<p class="small text-body-secondary mb-0">{% trans "What the user signs in with." %}</p>
{% endif %}
</div>
<div class="col-lg-9 user-form-fields">
<div class="row g-3">
<div class="col-sm-6">{% bootstrap_field user_form.username %}</div>
<div class="col-sm-6">
{% if edited_user %}
<span class="form-label d-block">{% trans "Password" %}</span>
<div class="d-flex flex-wrap align-items-center gap-2">
<span>{% 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>
{% else %}
{% bootstrap_field user_form.password %}
{% endif %}
</div>
<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 class="col-12">{% bootstrap_field user_form.email %}</div>
</div>
{% endfor %}
<div class="form-check form-switch">
<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" for="{{ user_form.is_active.id_for_label }}">{% trans "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>
</div>
</div>
{% for error in user_form.user_permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</fieldset>
</section>
<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>
<section class="user-form-section">
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1" id="role-heading">{% trans "Role" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "What the user manages." %}</p>
</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 class="col-lg-9 user-form-fields">
{% trans "Uses the VMs given to them, within their quota." as user_help %}
{% trans "May change and delete the template VMs given to them." as editor_help %}
{% trans "Manages the panel: every permission, no quota." as superuser_help %}
<div class="row row-cols-1 row-cols-md-3 g-2" role="radiogroup" aria-labelledby="role-heading">
{% for choice in user_form.role %}
<div class="col">
<label class="user-form-role border rounded p-3 d-flex gap-2 h-100">
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="role" value="{{ choice.data.value }}"{% if choice.data.selected %} checked{% endif %}>
<span><span class="d-block fw-semibold">{{ choice.choice_label }}</span>
<span class="small text-body-secondary">{% if choice.data.value == "superuser" %}{{ superuser_help }}{% elif choice.data.value == "template_editor" %}{{ editor_help }}{% else %}{{ user_help }}{% endif %}</span></span>
</label>
</div>
{% endfor %}
</div>
{% for error in user_form.role.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
</div>
{% endif %}
</div>
</details>
</section>
{% 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>
{# user_form.js hides the sections a superuser does not need while Superuser is chosen #}
{% with superuser=user_form.role.value %}
<p id="superuser-note" class="user-form-section small text-body-secondary mb-0"{% if superuser != "superuser" %} hidden{% endif %}>
{% trans "A superuser has every permission and no quota: there is nothing more to set." %}
</p>
<section class="user-form-section" data-not-superuser{% if superuser == "superuser" %} hidden{% endif %}>
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Quota" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "The most the user's VMs may have in total." %}</p>
</div>
<div class="col-lg-9 user-form-fields">
<div class="row row-cols-2 row-cols-md-4 g-3">
{% for field, unlimited, unit in attributes_form.quota_rows %}
<div class="col">
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
<div class="input-group{% if field.errors %} has-validation{% endif %}">
<input id="{{ field.id_for_label }}" class="form-control{% if field.errors %} is-invalid{% endif %}" type="number" name="{{ field.html_name }}"
min="0" step="{% if field.name == 'max_memory' %}any{% else %}1{% endif %}" value="{{ field.value|default_if_none:'' }}"{% if unlimited.value %} disabled{% endif %}>
{% if unit %}<span class="input-group-text">{{ unit }}</span>{% endif %}
{% for error in field.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
</div>
<div class="form-check mt-1">
<input id="{{ unlimited.id_for_label }}" class="form-check-input" type="checkbox" name="{{ unlimited.html_name }}"
data-no-limit="{{ field.id_for_label }}"{% if unlimited.value %} checked{% endif %}>
<label class="form-check-label small" for="{{ unlimited.id_for_label }}">{{ unlimited.label }}</label>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</details>
</section>
<section class="user-form-section" data-not-superuser{% if superuser == "superuser" %} hidden{% endif %}>
<div class="row g-3">
<div class="col-lg-3">
<h2 class="h6 fw-semibold mb-1">{% trans "Groups and permissions" %}</h2>
<p class="small text-body-secondary mb-0">{% trans "The permissions of the user's groups apply too." %}</p>
</div>
<div class="col-lg-9 user-form-fields">
<fieldset class="mb-3">
<legend class="visually-hidden">{% 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>
<legend class="visually-hidden">{% 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>
</div>
</div>
</section>
{% endwith %}
<div class="d-flex justify-content-end gap-2 mt-3">
<div class="user-form-section d-flex justify-content-end gap-2">
<a class="btn btn-outline-secondary" href="{% url 'admin:user_list' %}">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Save" %}</button>
<button type="submit" class="btn btn-primary">{% if edited_user %}{% trans "Save changes" %}{% else %}{% trans "Create user" %}{% endif %}</button>
</div>
</form>
{% endblock content %}
{% block script %}
<script src="{% static 'js/user_form.js' %}"></script>
{% endblock script %}

View file

@ -6,7 +6,7 @@ from django.urls import reverse
from logs.models import Logs
ATTRIBUTES = {"max_instances": 2, "max_cpus": 2, "max_memory": 2048, "max_disk_size": 20}
ATTRIBUTES = {"role": "user", "max_instances": 2, "max_cpus": 2, "max_memory": 2, "max_disk_size": 20}
def rows():

View file

@ -11,7 +11,7 @@ from computes.models import Compute
from instances.models import Instance
RAW = "Raw-Passw0rd-in-the-form"
LIMITS = {"max_instances": 1, "max_cpus": 1, "max_memory": 1024, "max_disk_size": 4}
LIMITS = {"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 4}
class UserCreateTests(TestCase):

View file

@ -22,8 +22,8 @@ class UserPagesTests(TestCase):
return [str(m) for m in get_messages(response.wsgi_request)]
def form_data(self, user, **changes):
data = {"username": user.username, "is_active": "on", "is_superuser": "on" if user.is_superuser else "",
"max_instances": "2", "max_cpus": "2", "max_memory": "2048", "max_disk_size": "20", **changes}
data = {"username": user.username, "is_active": "on", "role": "superuser" if user.is_superuser else "user",
"max_instances": "2", "max_cpus": "2", "max_memory": "2", "max_disk_size": "20", **changes}
return {key: value for key, value in data.items() if value != ""}
def test_an_administrator_cannot_block_delete_or_demote_themselves(self):
@ -31,7 +31,7 @@ class UserPagesTests(TestCase):
for response in (self.client.post(reverse("admin:user_block", args=[own])),
self.client.post(reverse("admin:user_delete", args=[own]))):
self.assertIn("You cannot block, delete or demote your own account", self.messages(response))
for change in ({"is_superuser": ""}, {"is_active": ""}):
for change in ({"role": "user"}, {"is_active": ""}):
response = self.client.post(reverse("admin:user_update", args=[own]), self.form_data(self.admin, **change))
self.assertContains(response, "You cannot block, delete or demote your own account")
self.admin.refresh_from_db()
@ -44,33 +44,62 @@ 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])])
self.assertEqual(page.xpath("//a[@href='../password/']"), [])
def test_staff_and_quotas_say_what_they_mean(self):
def test_roles_say_what_they_mean(self):
page = self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content.decode()
self.assertIn("Template editor", page)
self.assertIn("May change and delete the template VMs given to them", page)
self.assertIn("Manages the panel: every permission, no quota.", page)
self.assertNotIn("log into this admin site", page)
self.assertIn("Max memory (MiB)", page)
self.assertIn("Max disk size (GiB)", 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})
"role": "user", "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)
def test_the_quota_shows_no_limit_and_memory_in_gib(self):
UserAttributes.objects.filter(user=self.alice).update(max_instances=-1, max_cpus=1, max_memory=1536, 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")
shown = {box.get("name"): (box.get("value"), box.get("disabled") is not None) for box in page.xpath("//input[@type='number']")}
self.assertEqual(shown, {"max_instances": ("", True), "max_cpus": ("1", False), "max_memory": ("1.5", False),
"max_disk_size": ("", True)})
self.assertEqual(page.xpath("//input[@checked][@data-no-limit]/@name"), ["max_instances_unlimited", "max_disk_size_unlimited"])
def test_a_new_user_starts_with_the_default_quota(self):
page = html.fromstring(self.client.get(reverse("admin:user_create")).content)
shown = {box.get("name"): box.get("value") for box in page.xpath("//input[@type='number']")}
self.assertEqual(shown, {"max_instances": "2", "max_cpus": "2", "max_memory": "2", "max_disk_size": "20"})
self.assertEqual(page.xpath("//input[@name='role'][@checked]/@value"), ["user"])
def test_no_limit_is_kept_as_minus_one_and_gib_as_mib(self):
response = self.client.post(reverse("admin:user_update", args=[self.alice.pk]),
self.form_data(self.alice, max_cpus_unlimited="on", max_memory="0.75"))
self.assertRedirects(response, reverse("admin:user_list"))
quota = UserAttributes.objects.get(user=self.alice)
self.assertEqual((quota.max_instances, quota.max_cpus, quota.max_memory, quota.max_disk_size), (2, -1, 768, 20))
def test_a_limit_needs_a_number_or_no_limit(self):
for value in ("", "-1"):
with self.subTest(value=value):
response = self.client.post(reverse("admin:user_update", args=[self.alice.pk]),
self.form_data(self.alice, max_cpus=value) | {"max_cpus": value})
self.assertEqual(response.status_code, 200)
self.assertEqual(len(html.fromstring(response.content).xpath("//input[@name='max_cpus'][contains(@class, 'is-invalid')]")), 1)
def test_a_role_sets_staff_and_superuser(self):
url = reverse("admin:user_update", args=[self.alice.pk])
for role, flags in (("template_editor", (True, False)), ("superuser", (True, True)), ("user", (False, False))):
with self.subTest(role=role):
self.assertRedirects(self.client.post(url, self.form_data(self.alice, role=role)), reverse("admin:user_list"))
self.alice.refresh_from_db()
self.assertEqual((self.alice.is_staff, self.alice.is_superuser), flags)
def test_a_missing_users_profile_is_not_found(self):
self.assertEqual(self.client.get(reverse("accounts:account", args=[99999])).status_code, 404)

View file

@ -4,7 +4,7 @@ from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
LIMITS = {"max_instances": 1, "max_cpus": 1, "max_memory": 1024, "max_disk_size": 4}
LIMITS = {"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 4}
class UserUpdateNextTests(TestCase):

View file

@ -89,9 +89,10 @@ class AdminTestCase(TestCase):
{
"username": "test",
"password": "test",
"role": "user",
"max_instances": 1,
"max_cpus": 1,
"max_memory": 1024,
"max_memory": 1, # GiB
"max_disk_size": 4,
},
)
@ -114,9 +115,10 @@ class AdminTestCase(TestCase):
reverse("admin:user_update", args=[user_id]),
{
"username": "utest",
"role": "user",
"max_instances": 2,
"max_cpus": 2,
"max_memory": 2048,
"max_memory": 2, # GiB
"max_disk_size": 8,
},
)

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

11
static/js/user_form.js Normal file
View file

@ -0,0 +1,11 @@
// The user form (admin/user_form.html): a superuser needs no quota or
// permissions, so their sections hide while Superuser is chosen; a limit's
// No limit box disables its number, which the server then keeps as -1.
document.querySelectorAll('input[name=role]').forEach((radio) => radio.addEventListener('change', () => {
const superuser = radio.value === 'superuser';
document.querySelectorAll('[data-not-superuser]').forEach((section) => { section.hidden = superuser; });
document.getElementById('superuser-note').hidden = !superuser;
}));
document.querySelectorAll('[data-no-limit]').forEach((box) => box.addEventListener('change', () => {
document.getElementById(box.dataset.noLimit).disabled = box.checked;
}));