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