diff --git a/docker/orthos/devel-server.sh b/docker/orthos/devel-server.sh index 34134c198..5fe796153 100755 --- a/docker/orthos/devel-server.sh +++ b/docker/orthos/devel-server.sh @@ -30,10 +30,21 @@ server_start() { git switch "$OLD_BRANCH" git stash pop python3.11 manage.py migrate - # Load test machine fixtures for development - python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_domain_orthos2test.json || true - python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_machine_docker.json || true - DJANGO_SUPERUSER_PASSWORD="$ORTHOS2_SUPERUSER_PASSWORD" python3.11 manage.py createsuperuser --noinput --username admin --email admin@example.com + # Load test machine fixtures for development. Both fixtures use fixed + # keys/fqdns that collide with a previous run's data on the persistent dev + # DB, so skip loading them again if that data is already there. + python3.11 manage.py shell -c " +from orthos2.data.models import ServerConfig +exit(0 if ServerConfig.objects.filter(key='domain.validendings').exists() else 1) +" || python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_domain_orthos2test.json + python3.11 manage.py shell -c " +from orthos2.data.models import Machine +exit(0 if Machine.objects.filter(fqdn='testmachine.orthos2.test').exists() else 1) +" || python3.11 manage.py loaddata orthos2/data/fixtures/tests/test_machine_docker.json + python3.11 manage.py shell -c " +from django.contrib.auth.models import User +exit(0 if User.objects.filter(username='admin').exists() else 1) +" || DJANGO_SUPERUSER_PASSWORD="$ORTHOS2_SUPERUSER_PASSWORD" python3.11 manage.py createsuperuser --noinput --username admin --email admin@example.com python3.11 manage.py shell +
+
+
+
{{ group.name }}
+
+ + + + + + + + + {% for permission in group.permissions.all %} + + + + {% empty %} + + + + {% endfor %} + +
Permissions
{{ permission.content_type }} | {{ permission.name }}
No permissions assigned
+
+ +
+
+
Actions
+
+ +
+ +
+
+ + Edit: + {% url 'frontend:edit_group' group.id %} + +{% endblock %} diff --git a/orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html b/orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html new file mode 100644 index 000000000..940fe0a1c --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/group_confirm_deletion.html @@ -0,0 +1,20 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} +

Are you sure you want to delete group "{{ object.name }}"?

+ +
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/groups/groups.html b/orthos2/frontend/templates/frontend/groups/groups.html new file mode 100644 index 000000000..d1ee40c89 --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/groups.html @@ -0,0 +1,62 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load tags %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+ Found {{ paginator.count }} Group(s) + + + +
+
+
+ {% include 'frontend/snippet.paginator.html' %} + +
+ {% if page_obj %} + + + + + + + + + + + {% for group in page_obj %} + + + + + + {% endfor %} + +
NamePermissions 
+ {{ group.name }} + {{ group.permissions.count }} + + Delete + +
+ + {% include 'frontend/snippet.paginator.html' %} + {% else %} +
No groups found!
+ {% endif %} +
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/groups/new_group.html b/orthos2/frontend/templates/frontend/groups/new_group.html new file mode 100644 index 000000000..97fa66a75 --- /dev/null +++ b/orthos2/frontend/templates/frontend/groups/new_group.html @@ -0,0 +1,43 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+ {% csrf_token %} + + {{ form.non_field_errors }} + + {% for field in form %} + {{ field.errors }} +
+
+ +
+
+ {{ field }} + {{ field.help_text }} +
+
+ {% endfor %} + + +
+
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html b/orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html new file mode 100644 index 000000000..127dca8ac --- /dev/null +++ b/orthos2/frontend/templates/frontend/oidc_diagnostics/oidc_diagnostics.html @@ -0,0 +1,66 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
Associations
+
+ + + + + + + + + + + + {% for association in associations %} + + + + + + + + {% empty %} + + + + {% endfor %} + +
Server URLHandleAssoc TypeIssuedLifetime
{{ association.server_url }}{{ association.handle }}{{ association.assoc_type }}{{ association.issued }}{{ association.lifetime }}
No associations found.
+ +
+
Nonces
+
+ + + + + + + + + + {% for nonce in nonces %} + + + + + + {% empty %} + + + + {% endfor %} + +
Server URLTimestampSalt
{{ nonce.server_url }}{{ nonce.timestamp }}{{ nonce.salt }}
No nonces found.
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/snippet.navlinks.html b/orthos2/frontend/templates/frontend/snippet.navlinks.html index 79983a730..6a983bea7 100644 --- a/orthos2/frontend/templates/frontend/snippet.navlinks.html +++ b/orthos2/frontend/templates/frontend/snippet.navlinks.html @@ -108,7 +108,7 @@ REST API {% if request.user.is_superuser %} - {% active_group request 'users' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} + {% active_group request 'users' 'tokens' 'groups' 'oidc_diagnostics' 'serialconsoletypes' 'systems' 'remotepowertypes' 'architectures' 'serverconfigs' 'singletasks' 'dailytasks' 'domains' as admin_open %} {% endif %} diff --git a/orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html b/orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html new file mode 100644 index 000000000..4451ad7e0 --- /dev/null +++ b/orthos2/frontend/templates/frontend/tokens/token_confirm_deletion.html @@ -0,0 +1,24 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load tags %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} +

+ Are you sure you want to revoke the API token belonging to + "{{ object.user.username }}"? +

+ +
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/tokens/tokens.html b/orthos2/frontend/templates/frontend/tokens/tokens.html new file mode 100644 index 000000000..da900b033 --- /dev/null +++ b/orthos2/frontend/templates/frontend/tokens/tokens.html @@ -0,0 +1,54 @@ +{% extends 'frontend/base.html' %} +{% load static %} +{% load tags %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} + {% include 'frontend/snippet.paginator.html' %} + +
+ {% if page_obj %} + + + + + + + + + + + + {% for token in page_obj %} + + + + + + + {% endfor %} + +
OwnerKeyCreated 
+ {{ token.user.username }} + {{ token.key }}{{ token.created }} + + Revoke + +
+ + {% include 'frontend/snippet.paginator.html' %} + {% else %} +
No tokens found!
+ {% endif %} +
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html b/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html index c2840f02b..aa2147777 100644 --- a/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html +++ b/orthos2/frontend/templates/frontend/users/detail/snippets/sidebar.html @@ -10,3 +10,54 @@
Actions
>Reserve Machine +
+
+ Edit User +
+
+
+
+ {% csrf_token %} + +
+
+
+
+
+ {% csrf_token %} + +
+
+
+
+ Delete User +
diff --git a/orthos2/frontend/templates/frontend/users/detail/tokens.html b/orthos2/frontend/templates/frontend/users/detail/tokens.html new file mode 100644 index 000000000..f148b4dbe --- /dev/null +++ b/orthos2/frontend/templates/frontend/users/detail/tokens.html @@ -0,0 +1,39 @@ +{% extends 'frontend/users/user.html' %} + +{% block tabcontent %} +
+
+
Tokens
+
+ + {% if tokens %} + + + + + + + + + + {% for token in tokens %} + + + + + + {% endfor %} + +
KeyCreated 
{{ token.key }}{{ token.created }} + + Revoke + +
+ {% else %} +
No tokens for this user.
+ {% endif %} +
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/new_user.html b/orthos2/frontend/templates/frontend/users/new_user.html new file mode 100644 index 000000000..540001b6b --- /dev/null +++ b/orthos2/frontend/templates/frontend/users/new_user.html @@ -0,0 +1,43 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+
+ {% csrf_token %} + + {{ form.non_field_errors }} + + {% for field in form %} + {{ field.errors }} +
+
+ +
+
+ {{ field }} + {{ field.help_text }} +
+
+ {% endfor %} + + +
+
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/user.html b/orthos2/frontend/templates/frontend/users/user.html index 4af2df3bf..79bcd134f 100644 --- a/orthos2/frontend/templates/frontend/users/user.html +++ b/orthos2/frontend/templates/frontend/users/user.html @@ -21,6 +21,13 @@ >Reservations +
@@ -29,9 +36,9 @@
Admin: - {% url 'admin:auth_user_change' user_obj.id %}Edit: + {% url 'frontend:edit_user' user_obj.id %} diff --git a/orthos2/frontend/templates/frontend/users/user_confirm_deletion.html b/orthos2/frontend/templates/frontend/users/user_confirm_deletion.html new file mode 100644 index 000000000..f08ce7f41 --- /dev/null +++ b/orthos2/frontend/templates/frontend/users/user_confirm_deletion.html @@ -0,0 +1,20 @@ +{% extends 'frontend/base.html' %} +{% load static %} + +{% block navbar %} + {% include 'frontend/snippet.navlinks.html' %} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} +

Are you sure you want to delete user "{{ object.username }}"?

+ +
+
+{% endblock %} diff --git a/orthos2/frontend/templates/frontend/users/users_filterbar.html b/orthos2/frontend/templates/frontend/users/users_filterbar.html index 0b82ceb3e..e83ff069b 100644 --- a/orthos2/frontend/templates/frontend/users/users_filterbar.html +++ b/orthos2/frontend/templates/frontend/users/users_filterbar.html @@ -54,6 +54,10 @@ Found {{ paginator.count }} User(s) + + + + diff --git a/orthos2/frontend/tests/admin/test_group_views.py b/orthos2/frontend/tests/admin/test_group_views.py new file mode 100644 index 000000000..9b599bd48 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_group_views.py @@ -0,0 +1,184 @@ +"""Tests for the Group CRUD frontend views.""" + +from django.contrib.auth.models import Group, Permission, User +from django.test import TestCase +from django.urls import reverse + + +class GroupListViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:groups") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:groups") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_lists_groups(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:groups") + response = self.client.get(url) + assert response.status_code == 200 + assert b"Operators" in response.content + + +class GroupDetailViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.group = Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:group_detail", kwargs={"id": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:group_detail", kwargs={"id": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_detail_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:group_detail", kwargs={"id": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 200 + assert b"Operators" in response.content + + def test_nonexistent_group_returns_404(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:group_detail", kwargs={"id": 99999}) + response = self.client.get(url) + assert response.status_code == 404 + + +class NewGroupViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:new_group") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_group") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_group") + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_creates_group_with_permissions(self) -> None: + permission = Permission.objects.first() + assert permission is not None + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_group") + response = self.client.post( + url, {"name": "Operators", "permissions": [permission.pk]} + ) + assert response.status_code == 302 + group = Group.objects.get(name="Operators") + assert permission in group.permissions.all() + + def test_regular_user_post_does_not_create_group(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_group") + response = self.client.post(url, {"name": "Operators"}) + assert response.status_code == 403 + assert not Group.objects.filter(name="Operators").exists() + + +class GroupDetailedEditViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.group = Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_updates_group(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url, {"name": "Operators Renamed"}) + assert response.status_code == 302 + self.group.refresh_from_db() + assert self.group.name == "Operators Renamed" + + def test_regular_user_post_does_not_update_group(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url, {"name": "Operators Renamed"}) + assert response.status_code == 403 + self.group.refresh_from_db() + assert self.group.name == "Operators" + + +class DeleteGroupViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.group = Group.objects.create(name="Operators") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_confirmation_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_deletes_group(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert not Group.objects.filter(pk=self.group.pk).exists() + + def test_regular_user_post_does_not_delete_group(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_group", kwargs={"pk": self.group.pk}) + response = self.client.post(url) + assert response.status_code == 403 + assert Group.objects.filter(pk=self.group.pk).exists() diff --git a/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py b/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py new file mode 100644 index 000000000..b940e51a2 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_oidc_diagnostics_views.py @@ -0,0 +1,51 @@ +"""Tests for the read-only OIDC diagnostics (Association/Nonce) frontend view.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from social_django.models import Association, Nonce + + +class OidcDiagnosticsViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.association = Association.objects.create( + server_url="https://idp.example.test", + handle="assoc-handle-1", + secret="", + issued=0, + lifetime=0, + assoc_type="HMAC-SHA1", + ) + self.nonce = Nonce.objects.create( + server_url="https://idp.example.test", timestamp=0, salt="nonce-salt-1" + ) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_lists_associations_and_nonces(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert response.status_code == 200 + assert b"assoc-handle-1" in response.content + assert b"nonce-salt-1" in response.content + + def test_superuser_get_does_not_expose_association_secret(self) -> None: + self.association.secret = "super-secret-value" + self.association.save() + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:oidc_diagnostics") + response = self.client.get(url) + assert b"super-secret-value" not in response.content diff --git a/orthos2/frontend/tests/admin/test_token_views.py b/orthos2/frontend/tests/admin/test_token_views.py new file mode 100644 index 000000000..179c17d77 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_token_views.py @@ -0,0 +1,87 @@ +"""Tests for the Token management frontend views.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from rest_framework.authtoken.models import Token + + +class TokenListViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.owner = User.objects.get(username="user") + self.token = Token.objects.create(user=self.owner) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:tokens") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(self.owner) + url = reverse("frontend:tokens") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_lists_tokens(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:tokens") + response = self.client.get(url) + assert response.status_code == 200 + assert b"user" in response.content + + def test_superuser_get_shows_full_key(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:tokens") + response = self.client.get(url) + assert self.token.key.encode() in response.content + + def test_revoke_link_does_not_embed_key_in_url(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:tokens") + response = self.client.get(url) + delete_url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + assert delete_url.encode() in response.content + assert self.token.key not in delete_url + + +class DeleteTokenViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.owner = User.objects.get(username="user") + self.token = Token.objects.create(user=self.owner) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(self.owner) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_confirmation_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_revokes_token(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert not Token.objects.filter(pk=self.token.key).exists() + + def test_regular_user_post_does_not_revoke_token(self) -> None: + self.client.force_login(self.owner) + url = reverse("frontend:delete_token", kwargs={"user_id": self.owner.pk}) + response = self.client.post(url) + assert response.status_code == 403 + assert Token.objects.filter(pk=self.token.key).exists() diff --git a/orthos2/frontend/tests/admin/test_user_admin_views.py b/orthos2/frontend/tests/admin/test_user_admin_views.py new file mode 100644 index 000000000..1a56b0cc8 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_user_admin_views.py @@ -0,0 +1,234 @@ +"""Tests for the superuser User create/edit/delete/deactivate frontend views.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from social_django.models import UserSocialAuth + +from orthos2.taskmanager.models import SingleTask + + +class NewUserViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:new_user") + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_user") + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_user") + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_creates_user_with_unusable_password(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:new_user") + response = self.client.post( + url, + { + "username": "newperson", + "email": "newperson@foo.bar", + "first_name": "New", + "last_name": "Person", + "is_active": "on", + }, + ) + assert response.status_code == 302 + new_user = User.objects.get(username="newperson") + assert not new_user.has_usable_password() + + def test_regular_user_post_does_not_create_user(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:new_user") + response = self.client.post(url, {"username": "newperson"}) + assert response.status_code == 403 + assert not User.objects.filter(username="newperson").exists() + + +class UserDetailedEditViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user( + username="targetuser", email="target@foo.bar" + ) + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_form(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_updates_user(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.post( + url, + { + "username": "targetuser", + "email": "changed@foo.bar", + "first_name": "", + "last_name": "", + "is_active": "on", + "is_staff": "on", + }, + ) + assert response.status_code == 302 + self.target.refresh_from_db() + assert self.target.email == "changed@foo.bar" + assert self.target.is_staff + + def test_regular_user_post_does_not_update_user(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:edit_user", kwargs={"pk": self.target.pk}) + response = self.client.post(url, {"username": "targetuser", "is_staff": "on"}) + assert response.status_code == 403 + self.target.refresh_from_db() + assert not self.target.is_staff + + +class DeleteUserViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_confirmation_page(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_superuser_post_deletes_user(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert not User.objects.filter(pk=self.target.pk).exists() + + def test_regular_user_post_does_not_delete_user(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:delete_user", kwargs={"pk": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 403 + assert User.objects.filter(pk=self.target.pk).exists() + + +class UserToggleActiveViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser", is_active=True) + + def test_unauthenticated_post_redirects_to_login(self) -> None: + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_post_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 403 + + def test_superuser_post_deactivates_active_user(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert not self.target.is_active + + def test_superuser_post_reactivates_inactive_user(self) -> None: + self.target.is_active = False + self.target.save() + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_toggle_active", kwargs={"id": self.target.pk}) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert self.target.is_active + + +class UserSendPasswordResetViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser") + self.target.set_unusable_password() + self.target.save() + + def test_unauthenticated_post_redirects_to_login(self) -> None: + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_post_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 403 + + def test_superuser_post_sets_usable_password_and_queues_email(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert self.target.has_usable_password() + assert SingleTask.objects.filter(name="SendRestoredPassword").exists() + + def test_superuser_post_is_blocked_for_oidc_linked_user(self) -> None: + UserSocialAuth.objects.create( + user=self.target, provider="oidc", uid="oidc-uid-1" + ) + self.client.force_login(User.objects.get(username="superuser")) + url = reverse( + "frontend:user_send_password_reset", kwargs={"id": self.target.pk} + ) + response = self.client.post(url) + assert response.status_code == 302 + self.target.refresh_from_db() + assert not self.target.has_usable_password() + assert not SingleTask.objects.filter(name="SendRestoredPassword").exists() diff --git a/orthos2/frontend/tests/admin/test_user_tokens_tab.py b/orthos2/frontend/tests/admin/test_user_tokens_tab.py new file mode 100644 index 000000000..51f82c544 --- /dev/null +++ b/orthos2/frontend/tests/admin/test_user_tokens_tab.py @@ -0,0 +1,47 @@ +"""Tests for the per-user Tokens tab on the User detail page.""" + +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from rest_framework.authtoken.models import Token + + +class UserTokensViewTest(TestCase): + fixtures = ["orthos2/frontend/tests/user/fixtures/users.json"] + + def setUp(self) -> None: + self.target = User.objects.create_user(username="targetuser") + + def test_unauthenticated_get_redirects_to_login(self) -> None: + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 302 + assert "login" in response.url.lower() # type: ignore[attr-defined] + + def test_regular_user_get_is_forbidden(self) -> None: + self.client.force_login(User.objects.get(username="user")) + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 403 + + def test_superuser_get_shows_full_key_of_users_token(self) -> None: + token = Token.objects.create(user=self.target) + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + assert token.key.encode() in response.content + + def test_superuser_get_with_no_token_shows_empty_state(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + response = self.client.get(url) + assert response.status_code == 200 + + def test_user_detail_page_links_to_tokens_tab(self) -> None: + self.client.force_login(User.objects.get(username="superuser")) + url = reverse("frontend:user_detail", kwargs={"id": self.target.pk}) + response = self.client.get(url) + self.assertContains( + response, reverse("frontend:user_tokens", kwargs={"id": self.target.pk}) + ) diff --git a/orthos2/frontend/urls.py b/orthos2/frontend/urls.py index 116a309b4..8825f1589 100644 --- a/orthos2/frontend/urls.py +++ b/orthos2/frontend/urls.py @@ -253,6 +253,27 @@ r"^logout/$", auth_views.LogoutView.as_view(), {"next_page": "/"}, name="logout" ), path("users", views.UserListView.as_view(), name="users"), + path("users/new", views.NewUser.as_view(), name="new_user"), + path( + "users/edit//", + views.UserDetailedEdit.as_view(), + name="edit_user", + ), + path( + "users/delete//", + views.DeleteUser.as_view(), + name="delete_user", + ), + re_path( + r"^user/(?P[0-9]+)/toggle-active$", + views.user_toggle_active, + name="user_toggle_active", + ), + re_path( + r"^user/(?P[0-9]+)/send-password-reset$", + views.user_send_password_reset, + name="user_send_password_reset", + ), re_path( r"^user/(?P[0-9]+)/detail$", views.user_detail, @@ -268,11 +289,40 @@ views.user_reserve_machine, name="user_reserve_machine", ), + re_path( + r"^user/(?P[0-9]+)/tokens$", + views.user_tokens, + name="user_tokens", + ), re_path(r"^user/create$", views.users_create, name="create_user"), re_path(r"^user/preferences$", views.users_preferences, name="preferences_user"), re_path( r"^password/restore$", views.users_password_restore, name="password_restore" ), + path("oidc-diagnostics", views.oidc_diagnostics, name="oidc_diagnostics"), + path("groups", views.GroupListView.as_view(), name="groups"), + re_path( + r"^group/(?P[0-9]+)/detail$", + views.group_detail, + name="group_detail", + ), + path("groups/new", views.NewGroup.as_view(), name="new_group"), + path( + "groups/edit//", + views.GroupDetailedEdit.as_view(), + name="edit_group", + ), + path( + "groups/delete//", + views.DeleteGroup.as_view(), + name="delete_group", + ), + path("tokens", views.TokenListView.as_view(), name="tokens"), + path( + "tokens/delete//", + views.DeleteToken.as_view(), + name="delete_token", + ), re_path(r"^statistics$", views.statistics, name="statistics"), re_path( r"^ajax/machine/(?P[0-9]+)/annotation/add", diff --git a/orthos2/frontend/views/__init__.py b/orthos2/frontend/views/__init__.py index 866df15e1..202d394a5 100644 --- a/orthos2/frontend/views/__init__.py +++ b/orthos2/frontend/views/__init__.py @@ -66,6 +66,7 @@ enclosure_machines, enclosure_netboxcomparison, ) +from .group import DeleteGroup, GroupDetailedEdit, GroupListView, NewGroup, group_detail from .machine import ( BMCDetailedEdit, DeleteAnnotation, @@ -123,6 +124,7 @@ manufacturer_fetch_netbox, manufacturer_netboxcomparison, ) +from .oidc_diagnostics import oidc_diagnostics from .regenerate import ( regenerate_cobbler, regenerate_domain_cobbler, @@ -178,8 +180,20 @@ SystemListView, system_detail, ) +from .token import DeleteToken, TokenListView from .user import users_create, users_password_restore, users_preferences -from .users import UserListView, user_detail, user_reservations, user_reserve_machine +from .users import ( + DeleteUser, + NewUser, + UserDetailedEdit, + UserListView, + user_detail, + user_reservations, + user_reserve_machine, + user_send_password_reset, + user_toggle_active, + user_tokens, +) __all__ = [ "annotation", @@ -247,6 +261,11 @@ "enclosure_netboxcomparison", "enclosure_compare_netbox", "enclosure_fetch_netbox", + "GroupListView", + "NewGroup", + "GroupDetailedEdit", + "DeleteGroup", + "group_detail", "regenerate_cobbler", "regenerate_domain_cscreen", "regenerate_domain_cobbler", @@ -261,6 +280,14 @@ "user_detail", "user_reservations", "user_reserve_machine", + "user_tokens", + "NewUser", + "UserDetailedEdit", + "DeleteUser", + "user_toggle_active", + "user_send_password_reset", + "TokenListView", + "DeleteToken", "RemotePowerDevicesListView", "RemotePowerDeviceDetailedEdit", "NewRemotePowerDevice", @@ -278,6 +305,7 @@ "manufacturer_fetch_netbox", "manufacturer_netboxcomparison", "manufacturer_compare_netbox", + "oidc_diagnostics", "DeviceTypeListView", "NewDeviceType", "DeviceTypeDetailedEdit", diff --git a/orthos2/frontend/views/group.py b/orthos2/frontend/views/group.py new file mode 100644 index 000000000..f6edb1feb --- /dev/null +++ b/orthos2/frontend/views/group.py @@ -0,0 +1,82 @@ +""" +All views that are under "/groups". +""" + +from typing import Any, Dict + +from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import Group +from django.core.exceptions import PermissionDenied +from django.http import Http404, HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import CreateView, DeleteView, ListView, UpdateView + +from orthos2.frontend.forms.group import GroupForm +from orthos2.frontend.mixins import SuperuserRequiredMixin + + +class GroupListView(SuperuserRequiredMixin, ListView): + model = Group + template_name = "frontend/groups/groups.html" + paginate_by = 50 + ordering = "name" + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Groups" + return context + + +class NewGroup(SuperuserRequiredMixin, CreateView): + model = Group + template_name = "frontend/groups/new_group.html" + success_url = reverse_lazy("frontend:groups") + form_class = GroupForm + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "New Group" + context["action"] = "new" + return context + + +class GroupDetailedEdit(SuperuserRequiredMixin, UpdateView): + model = Group + template_name = "frontend/groups/new_group.html" + success_url = reverse_lazy("frontend:groups") + form_class = GroupForm + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Edit Group" + context["action"] = "edit" + return context + + +class DeleteGroup(SuperuserRequiredMixin, DeleteView): # type: ignore + model = Group + template_name = "frontend/groups/group_confirm_deletion.html" + success_url = reverse_lazy("frontend:groups") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Delete Group" + return context + + +@login_required +def group_detail(request: HttpRequest, id: int) -> HttpResponse: + if not request.user.is_superuser: # type: ignore + raise PermissionDenied + + try: + group = Group.objects.prefetch_related("permissions").get(pk=id) + except Group.DoesNotExist: + raise Http404("Group does not exist") + + return render( + request, + "frontend/groups/detail/overview.html", + {"group": group, "title": "Group {}".format(group.name)}, + ) diff --git a/orthos2/frontend/views/oidc_diagnostics.py b/orthos2/frontend/views/oidc_diagnostics.py new file mode 100644 index 000000000..96cc0edce --- /dev/null +++ b/orthos2/frontend/views/oidc_diagnostics.py @@ -0,0 +1,28 @@ +""" +Read-only superuser view of social-django's OIDC handshake bookkeeping +tables (Association, Nonce). Neither is written or read by any Orthos2 +code - they exist purely to support the OIDC login handshake, and this +view exists only so a superuser can inspect them while troubleshooting. +""" + +from django.contrib.auth.decorators import login_required +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from social_django.models import Association, Nonce + + +@login_required +def oidc_diagnostics(request: HttpRequest) -> HttpResponse: + if not request.user.is_superuser: # type: ignore + raise PermissionDenied + + return render( + request, + "frontend/oidc_diagnostics/oidc_diagnostics.html", + { + "associations": Association.objects.all().order_by("-issued"), + "nonces": Nonce.objects.all().order_by("-timestamp"), + "title": "OIDC Diagnostics", + }, + ) diff --git a/orthos2/frontend/views/token.py b/orthos2/frontend/views/token.py new file mode 100644 index 000000000..f865f7202 --- /dev/null +++ b/orthos2/frontend/views/token.py @@ -0,0 +1,51 @@ +""" +All views that are under "/tokens". +""" + +from typing import Any, Dict, Optional + +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 +from django.urls import reverse_lazy +from django.views.generic import DeleteView, ListView +from rest_framework.authtoken.models import Token + +from orthos2.frontend.mixins import SuperuserRequiredMixin + + +class TokenListView(SuperuserRequiredMixin, ListView): # type: ignore + model = Token + template_name = "frontend/tokens/tokens.html" + paginate_by = 50 + + def get_queryset(self) -> "QuerySet[Token]": + return ( + super() + .get_queryset() + .select_related("user") # type: ignore[attr-defined] + .order_by("user__username") + ) + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Tokens" + return context + + +class DeleteToken(SuperuserRequiredMixin, DeleteView): # type: ignore + """ + Keyed on the owning user's id, not the token's key, so the secret key + never ends up in a URL (browser history, server access logs, referers). + """ + + model = Token + template_name = "frontend/tokens/token_confirm_deletion.html" + success_url = reverse_lazy("frontend:tokens") + + def get_object(self, queryset: Optional["QuerySet[Token]"] = None) -> Token: + return get_object_or_404(Token, user_id=self.kwargs["user_id"]) + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Revoke Token" + return context diff --git a/orthos2/frontend/views/user.py b/orthos2/frontend/views/user.py index d80b914f5..6ba4638e4 100644 --- a/orthos2/frontend/views/user.py +++ b/orthos2/frontend/views/user.py @@ -29,6 +29,22 @@ from orthos2.taskmanager.models import TaskManager +def reset_and_notify_password(user: User) -> None: + """ + Set `user` a new random password and email it via `SendRestoredPassword`. + + Shared by the self-service "forgot password" flow and the superuser + "send password reset email" action on a User's edit page. + """ + alphabet = string.ascii_letters + string.digits + password = "".join(secrets.choice(alphabet) for i in range(10)) # type: ignore + user.set_password(password) + user.save() + + task = tasks.SendRestoredPassword(user.id, password) # type: ignore + TaskManager.add(task) + + def users_create( request: HttpRequest, ) -> Union[HttpResponsePermanentRedirect, HttpResponseRedirect, HttpResponse]: @@ -94,13 +110,7 @@ def users_password_restore( messages.error(request, "E-Mail/login does not exist.") return redirect("frontend:password_restore") - alphabet = string.ascii_letters + string.digits - password = "".join(secrets.choice(alphabet) for i in range(10)) # type: ignore - user.set_password(password) - user.save() - - task = tasks.SendRestoredPassword(user.id, password) # type: ignore - TaskManager.add(task) + reset_and_notify_password(user) # check for multiple accounts from deprecated Orthos task = tasks.CheckMultipleAccounts(user.id) # type: ignore diff --git a/orthos2/frontend/views/users.py b/orthos2/frontend/views/users.py index 40f7e1500..d90d8a2b0 100644 --- a/orthos2/frontend/views/users.py +++ b/orthos2/frontend/views/users.py @@ -17,11 +17,16 @@ HttpResponseRedirect, ) from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse_lazy from django.utils.decorators import method_decorator -from django.views.generic import ListView +from django.views.generic import CreateView, DeleteView, ListView, UpdateView +from rest_framework.authtoken.models import Token from orthos2.data.models import Machine from orthos2.frontend.forms.reservemachine import ReserveMachineForUserForm +from orthos2.frontend.forms.useradmin import UserAdminForm +from orthos2.frontend.mixins import SuperuserRequiredMixin +from orthos2.frontend.views.user import reset_and_notify_password class UserListView(ListView): # type: ignore @@ -81,6 +86,89 @@ def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: return context +class NewUser(SuperuserRequiredMixin, CreateView): + model = User + template_name = "frontend/users/new_user.html" + success_url = reverse_lazy("frontend:users") + form_class = UserAdminForm + + def form_valid(self, form: UserAdminForm) -> HttpResponseRedirect: + self.object = form.save(commit=False) + # No password field on this form - the account starts with no usable + # password; a superuser grants access via "Send password reset email". + self.object.set_unusable_password() + self.object.save() + form.save_m2m() + messages.success( + self.request, + f"User '{self.object.username}' created. " + "Use 'Send password reset email' on their profile to grant access.", + ) + return redirect(self.get_success_url()) + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "New User" + context["action"] = "new" + return context + + +class UserDetailedEdit(SuperuserRequiredMixin, UpdateView): + model = User + template_name = "frontend/users/new_user.html" + success_url = reverse_lazy("frontend:users") + form_class = UserAdminForm + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Edit User" + context["action"] = "edit" + return context + + +class DeleteUser(SuperuserRequiredMixin, DeleteView): # type: ignore + model = User + template_name = "frontend/users/user_confirm_deletion.html" + success_url = reverse_lazy("frontend:users") + + def get_context_data(self, **kwargs: Any) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + context["title"] = "Delete User" + return context + + +@login_required +def user_toggle_active(request: HttpRequest, id: int) -> HttpResponseRedirect: + if not request.user.is_superuser: + raise PermissionDenied + user_obj = get_object_or_404(User, pk=id) + if request.method == "POST": + user_obj.is_active = not user_obj.is_active + user_obj.save() + state = "activated" if user_obj.is_active else "deactivated" + messages.success(request, f"User '{user_obj.username}' {state}.") + return redirect("frontend:user_detail", id=id) + + +@login_required +def user_send_password_reset(request: HttpRequest, id: int) -> HttpResponseRedirect: + if not request.user.is_superuser: + raise PermissionDenied + user_obj = get_object_or_404(User, pk=id) + if request.method == "POST": + if user_obj.social_auth.exists(): # type: ignore[attr-defined] + messages.error( + request, + f"'{user_obj.username}' logs in via OIDC - no local password to reset.", + ) + else: + reset_and_notify_password(user_obj) + messages.success( + request, f"Password reset email sent to '{user_obj.username}'." + ) + return redirect("frontend:user_detail", id=id) + + @login_required def user_detail(request: HttpRequest, id: int) -> HttpResponse: if not request.user.is_superuser: @@ -120,6 +208,23 @@ def user_reservations(request: HttpRequest, id: int) -> HttpResponse: raise Http404("User does not exist") +@login_required +def user_tokens(request: HttpRequest, id: int) -> HttpResponse: + if not request.user.is_superuser: + raise PermissionDenied + user_obj = get_object_or_404(User, pk=id) + tokens = Token.objects.filter(user=user_obj) + return render( + request, + "frontend/users/detail/tokens.html", + { + "user_obj": user_obj, + "tokens": tokens, + "title": f"User {user_obj.username} Tokens", + }, + ) + + @login_required def user_reserve_machine( request: HttpRequest, id: int diff --git a/orthos2/settings.py b/orthos2/settings.py index 31b61a8cf..fab3e672a 100644 --- a/orthos2/settings.py +++ b/orthos2/settings.py @@ -77,7 +77,6 @@ def _environ_get_and_map( "orthos2.meta.apps.MetaConfig", "orthos2.taskmanager.apps.TaskManagerConfig", "orthos2.utils.apps.UtilsConfig", - "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", diff --git a/orthos2/taskmanager/admin.py b/orthos2/taskmanager/admin.py deleted file mode 100644 index 5805db308..000000000 --- a/orthos2/taskmanager/admin.py +++ /dev/null @@ -1 +0,0 @@ -# All data models are managed through the frontend and API CRUD views instead of Django Admin. diff --git a/orthos2/urls.py b/orthos2/urls.py index d34ca15b9..34caef502 100644 --- a/orthos2/urls.py +++ b/orthos2/urls.py @@ -15,12 +15,10 @@ 1. Import the include() function: from django.urls import url, include 2. Add a URL to urlpatterns: re_path(r'^blog/', include('blog.urls')) """ -from django.contrib import admin from django.urls import include, path, re_path urlpatterns = [ path("", include("social_django.urls", namespace="social")), re_path(r"^", include("orthos2.frontend.urls", namespace="frontend")), - re_path(r"^admin/", admin.site.urls), re_path(r"^api/", include("orthos2.api.urls", namespace="api")), ] diff --git a/orthos2/utils/admin.py b/orthos2/utils/admin.py deleted file mode 100644 index 846f6b406..000000000 --- a/orthos2/utils/admin.py +++ /dev/null @@ -1 +0,0 @@ -# Register your models here.