From 379cbdcc287de12f824322ae9d2543d8550d3b06 Mon Sep 17 00:00:00 2001 From: Matt Gros <3311227+mpge@users.noreply.github.com> Date: Sun, 10 May 2026 18:50:59 -0400 Subject: [PATCH] feat(admin): users-management page with admin/agent role toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the admin User-management surface from escalated-laravel#94 so the shared `Escalated/Admin/Users/Index` Inertia page works against the Django host. Adds: - GET /support/admin/users (`escalated:admin_users_index`) — paginated users list with name+email LIKE search, exposing is_admin / is_agent per row plus currentUserId. - PATCH /support/admin/users//role (`escalated:admin_users_role`) — toggle admin or agent. Promoting to admin also marks agent; revoking agent from an admin cascades to is_admin=false. Self-demote is blocked with a redirect-back so admins cannot lock themselves out of the panel they are using. Django has no first-class is_admin / is_agent columns, so the package maps is_admin <-> User.is_staff and is_agent <-> membership in any active Department. Hosts that model roles differently can override the controller as usual. Tested via pytest (8 cases covering list, forbidden, search, promote admin, promote agent only, self-demote guard, agent->admin cascade, role-forbidden). Ref: https://github.com/escalated-dev/escalated-laravel/pull/94 --- escalated/urls.py | 3 + escalated/views/admin.py | 176 +++++++++++++++ tests/integration/test_admin_users_views.py | 226 ++++++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 tests/integration/test_admin_users_views.py diff --git a/escalated/urls.py b/escalated/urls.py index 8230227..5ee63a4 100644 --- a/escalated/urls.py +++ b/escalated/urls.py @@ -267,6 +267,9 @@ ), # Reports path("admin/reports/dashboard/", admin.reports_dashboard, name="admin_reports_dashboard"), + # Users management + path("admin/users/", admin.users_index, name="admin_users_index"), + path("admin/users//role/", admin.users_role, name="admin_users_role"), # Workflows path("admin/workflows/", workflows.workflow_list, name="admin_workflows"), path("admin/workflows/create/", workflows.workflow_create, name="admin_workflow_create"), diff --git a/escalated/views/admin.py b/escalated/views/admin.py index ed5440b..b239ff3 100644 --- a/escalated/views/admin.py +++ b/escalated/views/admin.py @@ -3821,3 +3821,179 @@ def reports_dashboard(request): "agent_performance": service.get_agent_performance(start, end), }, ) + + +# --------------------------------------------------------------------------- +# Users management (admin-only) +# --------------------------------------------------------------------------- +# +# Surfaces host User rows so an admin can grant or revoke agent / admin +# access from the panel. Django has no first-class is_admin/is_agent +# columns, so we map: +# is_admin <-> User.is_staff (the host gate the rest of the package uses) +# is_agent <-> membership in any active Department +# Hosts that model roles differently (django-guardian, custom AUTH_USER_MODEL, +# etc.) can override these views in their own urls.py. + + +def _user_is_admin_flag(user) -> bool: + return bool(getattr(user, "is_staff", False) or getattr(user, "is_superuser", False)) + + +def _user_is_agent_flag(user, agent_user_ids: set[int]) -> bool: + return user.pk in agent_user_ids + + +def _grant_agent(user) -> None: + """Attach the user to an active department so they count as an agent.""" + department = Department.objects.filter(is_active=True).order_by("pk").first() + if department is None: + department = Department.objects.create( + name="Support", + slug="support", + description="Default support department.", + is_active=True, + ) + department.agents.add(user) + + +def _revoke_agent(user) -> None: + """Remove the user from every department they belong to.""" + for department in Department.objects.filter(agents=user): + department.agents.remove(user) + + +@login_required +def users_index(request): + """List host users with their admin/agent flags for an admin to manage.""" + check = _require_admin(request) + if check: + return check + + search = (request.GET.get("search") or "").strip() + qs = User.objects.all() + if search: + qs = qs.filter( + Q(email__icontains=search) + | Q(first_name__icontains=search) + | Q(last_name__icontains=search) + | Q(username__icontains=search) + ) + + # Compute is_admin / is_agent columns. Active department membership is + # the canonical agent signal in this package (see escalated.permissions). + qs = qs.order_by("-is_staff", "pk") + paginator = Paginator(qs, 20) + page = paginator.get_page(request.GET.get("page", 1)) + + page_user_ids = [u.pk for u in page.object_list] + agent_user_ids = set( + User.objects.filter(escalated_departments__is_active=True, pk__in=page_user_ids) + .values_list("pk", flat=True) + .distinct() + ) + + # Frontend sort order: is_admin desc, is_agent desc, id asc. is_staff is + # already DB-sorted; tiebreak by is_agent here in Python. + rows = [ + { + "id": u.pk, + "name": (u.get_full_name() or u.username) if hasattr(u, "get_full_name") else (u.username or None), + "email": u.email, + "is_admin": _user_is_admin_flag(u), + "is_agent": _user_is_agent_flag(u, agent_user_ids), + } + for u in page.object_list + ] + rows.sort(key=lambda r: (not r["is_admin"], not r["is_agent"], r["id"])) + + return render_page( + request, + "Escalated/Admin/Users/Index", + props={ + "users": { + "data": rows, + "current_page": page.number, + "last_page": paginator.num_pages, + "per_page": paginator.per_page, + "total": paginator.count, + "has_next": page.has_next(), + "has_previous": page.has_previous(), + }, + "filters": {"search": search}, + "currentUserId": request.user.pk if request.user.is_authenticated else None, + }, + ) + + +@login_required +def users_role(request, user_id): + """Toggle the admin or agent flag for a user.""" + check = _require_admin(request) + if check: + return check + + if request.method not in ("POST", "PATCH"): + return HttpResponseForbidden(_("Method not allowed")) + + # Parse body — support form-encoded and JSON. + if request.content_type and "json" in request.content_type: + try: + data = json.loads(request.body or b"{}") + except (json.JSONDecodeError, ValueError): + data = {} + else: + data = request.POST + + role = data.get("role") if isinstance(data, dict) else data.get("role", "") + raw_value = data.get("value") if isinstance(data, dict) else data.get("value", "") + + if role not in ("admin", "agent"): + return JsonResponse( + {"message": _("Validation failed."), "errors": {"role": _("Role must be admin or agent.")}}, + status=422, + ) + + if isinstance(raw_value, bool): + value = raw_value + elif isinstance(raw_value, str): + value = raw_value.lower() in ("1", "true", "on", "yes") + elif isinstance(raw_value, (int, float)): + value = bool(raw_value) + else: + return JsonResponse( + {"message": _("Validation failed."), "errors": {"value": _("Value must be boolean.")}}, + status=422, + ) + + try: + target = User.objects.get(pk=user_id) + except User.DoesNotExist: + return HttpResponseNotFound(_("User not found")) + + # Safety: an admin must not be able to demote themselves and lock + # themselves out of the admin panel they're using. + if role == "admin" and not value and str(request.user.pk) == str(target.pk): + return redirect("escalated:admin_users_index") + + if role == "admin": + target.is_staff = value + if value: + # Admins are agents (mirrors Laravel reference). + target.save() + _grant_agent(target) + return redirect("escalated:admin_users_index") + target.save() + else: + # role == "agent" + if value: + _grant_agent(target) + else: + _revoke_agent(target) + # Revoking agent from an admin would leave the admin gate on but + # the agent gate off — confusing. Demote them fully. + if _user_is_admin_flag(target): + target.is_staff = False + target.save() + + return redirect("escalated:admin_users_index") diff --git a/tests/integration/test_admin_users_views.py b/tests/integration/test_admin_users_views.py new file mode 100644 index 0000000..0670e40 --- /dev/null +++ b/tests/integration/test_admin_users_views.py @@ -0,0 +1,226 @@ +""" +Integration tests for the admin Users management views. + +Mirrors escalated-laravel#94 (UserController). Because the Django host User +model doesn't ship is_admin / is_agent columns, the package maps: + is_admin <-> User.is_staff + is_agent <-> membership in any active Department +""" + +import json +from unittest.mock import MagicMock, patch + +import pytest +from django.test import RequestFactory + +from escalated.views import admin +from tests.factories import DepartmentFactory, UserFactory + + +@pytest.fixture +def rf(): + return RequestFactory() + + +def _attach_session(request): + from django.contrib.sessions.backends.db import SessionStore + + request.session = SessionStore() + + +def _make_admin(username="admin_users"): + return UserFactory(username=username, is_staff=True, is_superuser=True) + + +def _make_agent(username="agent_users"): + user = UserFactory(username=username) + department = DepartmentFactory(slug=f"dept-{username}") + department.agents.add(user) + return user + + +# --------------------------------------------------------------------------- +# Index +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestAdminUsersIndex: + @patch("escalated.views.admin.render_page") + def test_index_lists_users_with_flags_for_admin(self, mock_render, rf): + admin_user = _make_admin("admin_idx") + _make_agent("agent_idx") + UserFactory(username="customer_idx", email="customer@example.com") + + mock_render.return_value = MagicMock(status_code=200) + + request = rf.get("/support/admin/users/") + request.user = admin_user + _attach_session(request) + + admin.users_index(request) + + mock_render.assert_called_once() + call_args = mock_render.call_args + props = call_args[1]["props"] if "props" in call_args[1] else call_args[0][2] + + assert "users" in props + emails = [row["email"] for row in props["users"]["data"]] + assert any(email.startswith("admin_idx") for email in emails) + assert "customer@example.com" in emails + assert any(email.startswith("agent_idx") for email in emails) + + # Verify is_admin / is_agent flags are surfaced on rows. + by_email = {row["email"]: row for row in props["users"]["data"]} + admin_row = next(r for e, r in by_email.items() if e.startswith("admin_idx")) + agent_row = next(r for e, r in by_email.items() if e.startswith("agent_idx")) + customer_row = by_email["customer@example.com"] + assert admin_row["is_admin"] is True + assert agent_row["is_agent"] is True + assert agent_row["is_admin"] is False + assert customer_row["is_admin"] is False + assert customer_row["is_agent"] is False + + # currentUserId reflects the request user. + assert props["currentUserId"] == admin_user.pk + + def test_index_forbidden_for_non_admin(self, rf): + agent = _make_agent("agent_forbid") + + request = rf.get("/support/admin/users/") + request.user = agent + _attach_session(request) + + response = admin.users_index(request) + assert response.status_code == 403 + + @patch("escalated.views.admin.render_page") + def test_index_filters_by_search_term(self, mock_render, rf): + admin_user = _make_admin("admin_search") + UserFactory(username="jane_acme", email="jane@acme.test") + UserFactory(username="bob_globex", email="bob@globex.test") + + mock_render.return_value = MagicMock(status_code=200) + + request = rf.get("/support/admin/users/?search=acme") + request.user = admin_user + _attach_session(request) + + admin.users_index(request) + + props = mock_render.call_args[1].get("props") or mock_render.call_args[0][2] + emails = [row["email"] for row in props["users"]["data"]] + assert "jane@acme.test" in emails + assert "bob@globex.test" not in emails + assert props["filters"]["search"] == "acme" + + +# --------------------------------------------------------------------------- +# updateRole +# --------------------------------------------------------------------------- + + +@pytest.mark.django_db +class TestAdminUsersRole: + def test_promote_to_admin_sets_is_staff_and_attaches_agent_department(self, rf): + admin_user = _make_admin("admin_promote") + target = UserFactory(username="someone_admin", email="someone@example.com") + DepartmentFactory(slug="default-promote") + + request = rf.post( + f"/support/admin/users/{target.pk}/role/", + data=json.dumps({"role": "admin", "value": True}), + content_type="application/json", + ) + request.user = admin_user + _attach_session(request) + + response = admin.users_role(request, target.pk) + + # back()-style redirect mirroring Laravel. + assert response.status_code == 302 + + target.refresh_from_db() + assert target.is_staff is True + # Admins are agents: target must now belong to at least one active dept. + from escalated.models import Department + + assert Department.objects.filter(agents=target, is_active=True).exists() + + def test_promote_to_agent_only_does_not_grant_admin(self, rf): + admin_user = _make_admin("admin_agentonly") + target = UserFactory(username="someone_agent", email="someone_a@example.com") + DepartmentFactory(slug="default-agentonly") + + request = rf.post( + f"/support/admin/users/{target.pk}/role/", + data=json.dumps({"role": "agent", "value": True}), + content_type="application/json", + ) + request.user = admin_user + _attach_session(request) + + response = admin.users_role(request, target.pk) + assert response.status_code == 302 + + target.refresh_from_db() + assert target.is_staff is False + from escalated.models import Department + + assert Department.objects.filter(agents=target, is_active=True).exists() + + def test_prevents_self_demote(self, rf): + admin_user = _make_admin("admin_selfdemote") + + request = rf.post( + f"/support/admin/users/{admin_user.pk}/role/", + data=json.dumps({"role": "admin", "value": False}), + content_type="application/json", + ) + request.user = admin_user + _attach_session(request) + + response = admin.users_role(request, admin_user.pk) + assert response.status_code == 302 + + admin_user.refresh_from_db() + # Crucially, the admin flag must NOT have been flipped. + assert admin_user.is_staff is True + + def test_revoking_agent_from_admin_cascades_to_remove_admin(self, rf): + admin_user = _make_admin("admin_cascade") + target = _make_agent("target_cascade") + target.is_staff = True + target.save() + + request = rf.post( + f"/support/admin/users/{target.pk}/role/", + data=json.dumps({"role": "agent", "value": False}), + content_type="application/json", + ) + request.user = admin_user + _attach_session(request) + + response = admin.users_role(request, target.pk) + assert response.status_code == 302 + + target.refresh_from_db() + assert target.is_staff is False + from escalated.models import Department + + assert not Department.objects.filter(agents=target, is_active=True).exists() + + def test_role_forbidden_for_non_admin(self, rf): + agent = _make_agent("agent_role_forbid") + target = UserFactory(username="target_role_forbid") + + request = rf.post( + f"/support/admin/users/{target.pk}/role/", + data=json.dumps({"role": "admin", "value": True}), + content_type="application/json", + ) + request.user = agent + _attach_session(request) + + response = admin.users_role(request, target.pk) + assert response.status_code == 403