From 7e3192100c7c50b0974f3319abfa7c9e39c5ef91 Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Thu, 3 Oct 2024 16:10:06 +0100 Subject: [PATCH 1/9] Refactor views --- process_manager/process_manager_interface.py | 103 ++++++++ process_manager/urls.py | 12 +- process_manager/views.py | 221 ------------------ process_manager/views/__init__.py | 1 + process_manager/views/actions.py | 31 +++ process_manager/views/pages.py | 69 ++++++ process_manager/views/partials.py | 54 +++++ tests/process_manager/views/__init__.py | 0 .../views/test_action_views.py | 47 ++++ .../test_page_views.py} | 109 +-------- .../views/test_partial_views.py | 49 ++++ tests/test_process_manager_interface.py | 13 ++ 12 files changed, 381 insertions(+), 328 deletions(-) create mode 100644 process_manager/process_manager_interface.py delete mode 100644 process_manager/views.py create mode 100644 process_manager/views/__init__.py create mode 100644 process_manager/views/actions.py create mode 100644 process_manager/views/pages.py create mode 100644 process_manager/views/partials.py create mode 100644 tests/process_manager/views/__init__.py create mode 100644 tests/process_manager/views/test_action_views.py rename tests/process_manager/{test_views.py => views/test_page_views.py} (50%) create mode 100644 tests/process_manager/views/test_partial_views.py create mode 100644 tests/test_process_manager_interface.py diff --git a/process_manager/process_manager_interface.py b/process_manager/process_manager_interface.py new file mode 100644 index 0000000..d3fc4e7 --- /dev/null +++ b/process_manager/process_manager_interface.py @@ -0,0 +1,103 @@ +"""Module providing functions to interact with the drunc process manager.""" + +import asyncio +from enum import Enum + +from django.conf import settings +from drunc.process_manager.process_manager_driver import ProcessManagerDriver +from drunc.utils.shell_utils import DecodedResponse, create_dummy_token_from_uname +from druncschema.process_manager_pb2 import ( + LogRequest, + ProcessInstanceList, + ProcessQuery, + ProcessUUID, +) + + +def get_process_manager_driver() -> ProcessManagerDriver: + """Get a ProcessManagerDriver instance.""" + token = create_dummy_token_from_uname() + return ProcessManagerDriver( + settings.PROCESS_MANAGER_URL, token=token, aio_channel=True + ) + + +async def _get_session_info() -> ProcessInstanceList: + pmd = get_process_manager_driver() + query = ProcessQuery(names=[".*"]) + return await pmd.ps(query) + + +def get_session_info() -> ProcessInstanceList: + """Get info about all sessions from process manager.""" + return asyncio.run(_get_session_info()) + + +class ProcessAction(Enum): + """Enum for process actions.""" + + RESTART = "restart" + KILL = "kill" + FLUSH = "flush" + + +async def _process_call(uuids: list[str], action: ProcessAction) -> None: + pmd = get_process_manager_driver() + uuids_ = [ProcessUUID(uuid=u) for u in uuids] + + match action: + case ProcessAction.RESTART: + for uuid_ in uuids_: + query = ProcessQuery(uuids=[uuid_]) + await pmd.restart(query) + case ProcessAction.KILL: + query = ProcessQuery(uuids=uuids_) + await pmd.kill(query) + case ProcessAction.FLUSH: + query = ProcessQuery(uuids=uuids_) + await pmd.flush(query) + + +def process_call(uuids: list[str], action: ProcessAction) -> None: + """Perform an action on a process with a given UUID. + + Args: + uuids: List of UUIDs of the process to be actioned. + action: Action to be performed {restart,flush,kill}. + """ + return asyncio.run(_process_call(uuids, action)) + + +async def _get_process_logs(uuid: str) -> list[DecodedResponse]: + pmd = get_process_manager_driver() + query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) + request = LogRequest(query=query, how_far=100) + return [item async for item in pmd.logs(request)] + + +def get_process_logs(uuid: str) -> list[DecodedResponse]: + """Retrieve logs for a process from the process manager. + + Args: + uuid: UUID of the process. + + Returns: + The process logs. + """ + return asyncio.run(_get_process_logs(uuid)) + + +async def _boot_process(user: str, data: dict[str, str | int]) -> None: + pmd = get_process_manager_driver() + async for item in pmd.dummy_boot(user=user, **data): + pass + + +def boot_process(user: str, data: dict[str, str | int]) -> None: + """Boot a process with the given data. + + Args: + user: the user to boot the process as. + data: the data for the process. + """ + return asyncio.run(_boot_process(user, data)) diff --git a/process_manager/urls.py b/process_manager/urls.py index 408cf0b..0378281 100644 --- a/process_manager/urls.py +++ b/process_manager/urls.py @@ -2,18 +2,18 @@ from django.urls import include, path -from . import views +from .views import actions, pages, partials app_name = "process_manager" partial_urlpatterns = [ - path("process_table/", views.process_table, name="process_table"), + path("process_table/", partials.process_table, name="process_table"), ] urlpatterns = [ - path("", views.index, name="index"), - path("process_action/", views.process_action, name="process_action"), - path("logs/", views.logs, name="logs"), - path("boot_process/", views.BootProcessView.as_view(), name="boot_process"), + path("", pages.index, name="index"), + path("process_action/", actions.process_action, name="process_action"), + path("logs/", pages.logs, name="logs"), + path("boot_process/", pages.BootProcessView.as_view(), name="boot_process"), path("partials/", include(partial_urlpatterns)), ] diff --git a/process_manager/views.py b/process_manager/views.py deleted file mode 100644 index 712c59f..0000000 --- a/process_manager/views.py +++ /dev/null @@ -1,221 +0,0 @@ -"""Views for the process_manager app.""" - -import asyncio -import uuid -from enum import Enum - -import django_tables2 -from django.conf import settings -from django.contrib.auth.decorators import login_required, permission_required -from django.contrib.auth.mixins import PermissionRequiredMixin -from django.db import transaction -from django.http import HttpRequest, HttpResponse, HttpResponseRedirect -from django.shortcuts import render -from django.urls import reverse, reverse_lazy -from django.views.generic.edit import FormView -from drunc.process_manager.process_manager_driver import ProcessManagerDriver -from drunc.utils.shell_utils import DecodedResponse, create_dummy_token_from_uname -from druncschema.process_manager_pb2 import ( - LogRequest, - ProcessInstance, - ProcessInstanceList, - ProcessQuery, - ProcessUUID, -) - -from .forms import BootProcessForm -from .tables import ProcessTable - - -def get_process_manager_driver() -> ProcessManagerDriver: - """Get a ProcessManagerDriver instance.""" - token = create_dummy_token_from_uname() - return ProcessManagerDriver( - settings.PROCESS_MANAGER_URL, token=token, aio_channel=True - ) - - -async def get_session_info() -> ProcessInstanceList: - """Get info about all sessions from process manager.""" - pmd = get_process_manager_driver() - query = ProcessQuery(names=[".*"]) - return await pmd.ps(query) - - -@login_required -def index(request: HttpRequest) -> HttpResponse: - """View that renders the index/home page.""" - with transaction.atomic(): - # atomic to avoid race condition with kafka consumer - messages = request.session.load().get("messages", []) - request.session.pop("messages", []) - request.session.save() - - context = {"messages": messages} - return render( - request=request, context=context, template_name="process_manager/index.html" - ) - - -@login_required -def process_table(request: HttpRequest) -> HttpResponse: - """Renders the process table. - - This view may be called using either GET or POST methods. GET renders the table with - no check boxes selected. POST renders the table with checked boxes for any table row - with a uuid provided in the select key of the request data. - """ - selected_rows = request.POST.getlist("select", []) - session_info = asyncio.run(get_session_info()) - - status_enum_lookup = dict(item[::-1] for item in ProcessInstance.StatusCode.items()) - - table_data = [] - process_instances = session_info.data.values - for process_instance in process_instances: - metadata = process_instance.process_description.metadata - uuid = process_instance.uuid.uuid - table_data.append( - { - "uuid": uuid, - "name": metadata.name, - "user": metadata.user, - "session": metadata.session, - "status_code": status_enum_lookup[process_instance.status_code], - "exit_code": process_instance.return_code, - "checked": (uuid in selected_rows), - } - ) - table = ProcessTable(table_data) - - # sort table data based on request parameters - table_configurator = django_tables2.RequestConfig(request) - table_configurator.configure(table) - - return render( - request=request, - context=dict(table=table), - template_name="process_manager/partials/process_table.html", - ) - - -class ProcessAction(Enum): - """Enum for process actions.""" - - RESTART = "restart" - KILL = "kill" - FLUSH = "flush" - - -async def _process_call(uuids: list[str], action: ProcessAction) -> None: - """Perform an action on a process with a given UUID. - - Args: - uuids: List of UUIDs of the process to be actioned. - action: Action to be performed {restart,flush,kill}. - """ - pmd = get_process_manager_driver() - uuids_ = [ProcessUUID(uuid=u) for u in uuids] - - match action: - case ProcessAction.RESTART: - for uuid_ in uuids_: - query = ProcessQuery(uuids=[uuid_]) - await pmd.restart(query) - case ProcessAction.KILL: - query = ProcessQuery(uuids=uuids_) - await pmd.kill(query) - case ProcessAction.FLUSH: - query = ProcessQuery(uuids=uuids_) - await pmd.flush(query) - - -@login_required -@permission_required("main.can_modify_processes", raise_exception=True) -def process_action(request: HttpRequest) -> HttpResponse: - """Perform an action on the selected processes. - - Both the action and the selected processes are retrieved from the request. - - Args: - request: Django HttpRequest object. - - Returns: - HttpResponse redirecting to the index page. - """ - try: - action = request.POST.get("action", "") - action_enum = ProcessAction(action.lower()) - except ValueError: - return HttpResponseRedirect(reverse("process_manager:index")) - - if uuids_ := request.POST.getlist("select"): - asyncio.run(_process_call(uuids_, action_enum)) - return HttpResponseRedirect(reverse("process_manager:index")) - - -async def _get_process_logs(uuid: str) -> list[DecodedResponse]: - """Retrieve logs for a process from the process manager. - - Args: - uuid: UUID of the process. - - Returns: - The process logs. - """ - pmd = get_process_manager_driver() - query = ProcessQuery(uuids=[ProcessUUID(uuid=uuid)]) - request = LogRequest(query=query, how_far=100) - return [item async for item in pmd.logs(request)] - - -@login_required -@permission_required("main.can_view_process_logs", raise_exception=True) -def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: - """Display the logs of a process. - - Args: - request: the triggering request. - uuid: identifier for the process. - - Returns: - The rendered page. - """ - logs_response = asyncio.run(_get_process_logs(str(uuid))) - context = dict(log_text="\n".join(val.data.line for val in logs_response)) - return render( - request=request, context=context, template_name="process_manager/logs.html" - ) - - -async def _boot_process(user: str, data: dict[str, str | int]) -> None: - """Boot a process with the given data. - - Args: - user: the user to boot the process as. - data: the data for the process. - """ - pmd = get_process_manager_driver() - async for item in pmd.dummy_boot(user=user, **data): - pass - - -class BootProcessView(PermissionRequiredMixin, FormView): # type: ignore [type-arg] - """View for the BootProcess form.""" - - template_name = "process_manager/boot_process.html" - form_class = BootProcessForm - success_url = reverse_lazy("process_manager:index") - permission_required = "main.can_modify_processes" - - def form_valid(self, form: BootProcessForm) -> HttpResponse: - """Boot a Process when valid form data has been POSTed. - - Args: - form: the form instance that has been validated. - - Returns: - A redirect to the index page. - """ - asyncio.run(_boot_process("root", form.cleaned_data)) - return super().form_valid(form) diff --git a/process_manager/views/__init__.py b/process_manager/views/__init__.py new file mode 100644 index 0000000..00c1b69 --- /dev/null +++ b/process_manager/views/__init__.py @@ -0,0 +1 @@ +"""Module for app view functions.""" diff --git a/process_manager/views/actions.py b/process_manager/views/actions.py new file mode 100644 index 0000000..eb41745 --- /dev/null +++ b/process_manager/views/actions.py @@ -0,0 +1,31 @@ +"""View functions for performing actions on DUNE processes.""" + +from django.contrib.auth.decorators import login_required, permission_required +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.urls import reverse + +from ..process_manager_interface import ProcessAction, process_call + + +@login_required +@permission_required("main.can_modify_processes", raise_exception=True) +def process_action(request: HttpRequest) -> HttpResponse: + """Perform an action on the selected processes. + + Both the action and the selected processes are retrieved from the request. + + Args: + request: Django HttpRequest object. + + Returns: + HttpResponse redirecting to the index page. + """ + try: + action = request.POST.get("action", "") + action_enum = ProcessAction(action.lower()) + except ValueError: + return HttpResponseRedirect(reverse("process_manager:index")) + + if uuids_ := request.POST.getlist("select"): + process_call(uuids_, action_enum) + return HttpResponseRedirect(reverse("process_manager:index")) diff --git a/process_manager/views/pages.py b/process_manager/views/pages.py new file mode 100644 index 0000000..48b9c75 --- /dev/null +++ b/process_manager/views/pages.py @@ -0,0 +1,69 @@ +"""View functions for pages.""" + +import uuid + +from django.contrib.auth.decorators import login_required, permission_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db import transaction +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic.edit import FormView + +from ..forms import BootProcessForm +from ..process_manager_interface import boot_process, get_process_logs + + +@login_required +def index(request: HttpRequest) -> HttpResponse: + """View that renders the index/home page.""" + with transaction.atomic(): + # atomic to avoid race condition with kafka consumer + messages = request.session.load().get("messages", []) + request.session.pop("messages", []) + request.session.save() + + context = {"messages": messages} + return render( + request=request, context=context, template_name="process_manager/index.html" + ) + + +@login_required +@permission_required("main.can_view_process_logs", raise_exception=True) +def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: + """Display the logs of a process. + + Args: + request: the triggering request. + uuid: identifier for the process. + + Returns: + The rendered page. + """ + logs_response = get_process_logs(str(uuid)) + context = dict(log_text="\n".join(val.data.line for val in logs_response)) + return render( + request=request, context=context, template_name="process_manager/logs.html" + ) + + +class BootProcessView(PermissionRequiredMixin, FormView): # type: ignore [type-arg] + """View for the BootProcess form.""" + + template_name = "process_manager/boot_process.html" + form_class = BootProcessForm + success_url = reverse_lazy("process_manager:index") + permission_required = "main.can_modify_processes" + + def form_valid(self, form: BootProcessForm) -> HttpResponse: + """Boot a Process when valid form data has been POSTed. + + Args: + form: the form instance that has been validated. + + Returns: + A redirect to the index page. + """ + boot_process("root", form.cleaned_data) + return super().form_valid(form) diff --git a/process_manager/views/partials.py b/process_manager/views/partials.py new file mode 100644 index 0000000..ab5d5b5 --- /dev/null +++ b/process_manager/views/partials.py @@ -0,0 +1,54 @@ +"""View functions for partials.""" + +import django_tables2 +from django.contrib.auth.decorators import login_required +from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from druncschema.process_manager_pb2 import ( + ProcessInstance, +) + +from ..process_manager_interface import get_session_info +from ..tables import ProcessTable + + +@login_required +def process_table(request: HttpRequest) -> HttpResponse: + """Renders the process table. + + This view may be called using either GET or POST methods. GET renders the table with + no check boxes selected. POST renders the table with checked boxes for any table row + with a uuid provided in the select key of the request data. + """ + selected_rows = request.POST.getlist("select", []) + session_info = get_session_info() + + status_enum_lookup = dict(item[::-1] for item in ProcessInstance.StatusCode.items()) + + table_data = [] + process_instances = session_info.data.values + for process_instance in process_instances: + metadata = process_instance.process_description.metadata + uuid = process_instance.uuid.uuid + table_data.append( + { + "uuid": uuid, + "name": metadata.name, + "user": metadata.user, + "session": metadata.session, + "status_code": status_enum_lookup[process_instance.status_code], + "exit_code": process_instance.return_code, + "checked": (uuid in selected_rows), + } + ) + table = ProcessTable(table_data) + + # sort table data based on request parameters + table_configurator = django_tables2.RequestConfig(request) + table_configurator.configure(table) + + return render( + request=request, + context=dict(table=table), + template_name="process_manager/partials/process_table.html", + ) diff --git a/tests/process_manager/views/__init__.py b/tests/process_manager/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/process_manager/views/test_action_views.py b/tests/process_manager/views/test_action_views.py new file mode 100644 index 0000000..1de3a8e --- /dev/null +++ b/tests/process_manager/views/test_action_views.py @@ -0,0 +1,47 @@ +from http import HTTPStatus +from uuid import uuid4 + +import pytest +from django.urls import reverse + +from process_manager.views.actions import ProcessAction + +from ...utils import LoginRequiredTest + + +class TestProcessActionView(LoginRequiredTest): + """Tests for the process_action view.""" + + endpoint = reverse("process_manager:process_action") + + def test_process_action_no_action(self, auth_process_client): + """Test process_action view with no action provided.""" + response = auth_process_client.post(self.endpoint, data={}) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + def test_process_action_invalid_action(self, auth_process_client): + """Test process_action view with an invalid action.""" + response = auth_process_client.post( + self.endpoint, data={"action": "invalid_action"} + ) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) + def test_process_action_valid_action(self, action, auth_process_client, mocker): + """Test process_action view with a valid action.""" + mock = mocker.patch("process_manager.process_manager_interface._process_call") + uuids_ = [str(uuid4()), str(uuid4())] + response = auth_process_client.post( + self.endpoint, data={"action": action, "select": uuids_} + ) + assert response.status_code == HTTPStatus.FOUND + assert response.url == reverse("process_manager:index") + + mock.assert_called_once_with(uuids_, ProcessAction(action)) + + def test_process_action_get_unprivileged(self, auth_client): + """Test the GET request for the process_action view (unprivileged).""" + response = auth_client.get(reverse("process_manager:boot_process")) + assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/tests/process_manager/test_views.py b/tests/process_manager/views/test_page_views.py similarity index 50% rename from tests/process_manager/test_views.py rename to tests/process_manager/views/test_page_views.py index 24e880a..7ce3efa 100644 --- a/tests/process_manager/test_views.py +++ b/tests/process_manager/views/test_page_views.py @@ -1,15 +1,10 @@ from http import HTTPStatus -from unittest.mock import MagicMock from uuid import uuid4 -import pytest from django.urls import reverse from pytest_django.asserts import assertContains, assertTemplateUsed -from process_manager.tables import ProcessTable -from process_manager.views import ProcessAction - -from ..utils import LoginRequiredTest +from ...utils import LoginRequiredTest class TestIndexView(LoginRequiredTest): @@ -19,14 +14,14 @@ class TestIndexView(LoginRequiredTest): def test_index_view_authenticated(self, auth_client, mocker): """Test the index view for an authenticated user.""" - mocker.patch("process_manager.views.get_session_info") + mocker.patch("process_manager.process_manager_interface.get_session_info") with assertTemplateUsed(template_name="process_manager/index.html"): response = auth_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK def test_index_view_admin(self, admin_client, mocker): """Test the index view for an admin user.""" - mocker.patch("process_manager.views.get_session_info") + mocker.patch("process_manager.process_manager_interface.get_session_info") with assertTemplateUsed(template_name="process_manager/index.html"): response = admin_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK @@ -37,7 +32,7 @@ def test_session_messages(self, auth_client, mocker): from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.models import Session - mocker.patch("process_manager.views.get_session_info") + mocker.patch("process_manager.process_manager_interface.get_session_info") session = Session.objects.get() message_data = ["message 1", "message 2"] store = SessionStore(session_key=session.session_key) @@ -65,7 +60,9 @@ def test_logs_view_unprivileged(self, auth_client): def test_logs_view_privileged(self, auth_logs_client, mocker): """Test the logs view for a privileged user.""" - mock = mocker.patch("process_manager.views._get_process_logs") + mock = mocker.patch( + "process_manager.process_manager_interface._get_process_logs" + ) with assertTemplateUsed(template_name="process_manager/logs.html"): response = auth_logs_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK @@ -74,44 +71,6 @@ def test_logs_view_privileged(self, auth_logs_client, mocker): assert "log_text" in response.context -class TestProcessActionView(LoginRequiredTest): - """Tests for the process_action view.""" - - endpoint = reverse("process_manager:process_action") - - def test_process_action_no_action(self, auth_process_client): - """Test process_action view with no action provided.""" - response = auth_process_client.post(self.endpoint, data={}) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("process_manager:index") - - def test_process_action_invalid_action(self, auth_process_client): - """Test process_action view with an invalid action.""" - response = auth_process_client.post( - self.endpoint, data={"action": "invalid_action"} - ) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("process_manager:index") - - @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) - def test_process_action_valid_action(self, action, auth_process_client, mocker): - """Test process_action view with a valid action.""" - mock = mocker.patch("process_manager.views._process_call") - uuids_ = [str(uuid4()), str(uuid4())] - response = auth_process_client.post( - self.endpoint, data={"action": action, "select": uuids_} - ) - assert response.status_code == HTTPStatus.FOUND - assert response.url == reverse("process_manager:index") - - mock.assert_called_once_with(uuids_, ProcessAction(action)) - - def test_process_action_get_unprivileged(self, auth_client): - """Test the GET request for the process_action view (unprivileged).""" - response = auth_client.get(reverse("process_manager:boot_process")) - assert response.status_code == HTTPStatus.FORBIDDEN - - class TestBootProcess(LoginRequiredTest): """Grouping the tests for the BootProcess view.""" @@ -148,7 +107,7 @@ def test_boot_process_post_valid( self, auth_process_client, mocker, dummy_session_data ): """Test the POST request for the BootProcess view.""" - mock = mocker.patch("process_manager.views._boot_process") + mock = mocker.patch("process_manager.process_manager_interface._boot_process") response = auth_process_client.post( reverse("process_manager:boot_process"), data=dummy_session_data ) @@ -157,55 +116,3 @@ def test_boot_process_post_valid( assert response.url == reverse("process_manager:index") mock.assert_called_once_with("root", dummy_session_data) - - -@pytest.mark.asyncio -async def test_boot_process(mocker, dummy_session_data): - """Test the _boot_process function.""" - from process_manager.views import _boot_process - - mock = mocker.patch("process_manager.views.get_process_manager_driver") - await _boot_process("root", dummy_session_data) - mock.assert_called_once() - mock.return_value.dummy_boot.assert_called_once_with( - user="root", **dummy_session_data - ) - - -class TestProcessTableView(LoginRequiredTest): - """Test the process_manager.views.process_table view function.""" - - endpoint = reverse("process_manager:process_table") - - @pytest.mark.parametrize("method", ("get", "post")) - def test_method(self, method, auth_client, mocker): - """Tests basic calls of view method.""" - self._mock_session_info(mocker, []) - response = getattr(auth_client, method)(self.endpoint) - assert response.status_code == HTTPStatus.OK - assert isinstance(response.context["table"], ProcessTable) - - def _mock_session_info(self, mocker, uuids): - """Mocks views.get_session_info with ProcessInstanceList like data.""" - mock = mocker.patch("process_manager.views.get_session_info") - instance_mocks = [MagicMock() for uuid in uuids] - for instance_mock, uuid in zip(instance_mocks, uuids): - instance_mock.uuid.uuid = str(uuid) - instance_mock.status_code = 0 - mock.data.values.__iter__.return_value = instance_mocks - return mock - - def test_post_checked_rows(self, mocker, auth_client): - """Tests table data is correct when post data is included.""" - all_uuids = [str(uuid4()) for _ in range(5)] - selected_uuids = all_uuids[::2] - - self._mock_session_info(mocker, all_uuids) - - response = auth_client.post(self.endpoint, data=dict(select=selected_uuids)) - assert response.status_code == HTTPStatus.OK - table = response.context["table"] - assert isinstance(table, ProcessTable) - - for row in table.data.data: - assert row["checked"] == (row["uuid"] in selected_uuids) diff --git a/tests/process_manager/views/test_partial_views.py b/tests/process_manager/views/test_partial_views.py new file mode 100644 index 0000000..55657a6 --- /dev/null +++ b/tests/process_manager/views/test_partial_views.py @@ -0,0 +1,49 @@ +from http import HTTPStatus +from unittest.mock import MagicMock +from uuid import uuid4 + +import pytest +from django.urls import reverse + +from process_manager.tables import ProcessTable + +from ...utils import LoginRequiredTest + + +class TestProcessTableView(LoginRequiredTest): + """Test the process_manager.views.process_table view function.""" + + endpoint = reverse("process_manager:process_table") + + @pytest.mark.parametrize("method", ("get", "post")) + def test_method(self, method, auth_client, mocker): + """Tests basic calls of view method.""" + self._mock_session_info(mocker, []) + response = getattr(auth_client, method)(self.endpoint) + assert response.status_code == HTTPStatus.OK + assert isinstance(response.context["table"], ProcessTable) + + def _mock_session_info(self, mocker, uuids): + """Mocks views.get_session_info with ProcessInstanceList like data.""" + mock = mocker.patch("process_manager.views.partials.get_session_info") + instance_mocks = [MagicMock() for uuid in uuids] + for instance_mock, uuid in zip(instance_mocks, uuids): + instance_mock.uuid.uuid = str(uuid) + instance_mock.status_code = 0 + mock.data.values.__iter__.return_value = instance_mocks + return mock + + def test_post_checked_rows(self, mocker, auth_client): + """Tests table data is correct when post data is included.""" + all_uuids = [str(uuid4()) for _ in range(5)] + selected_uuids = all_uuids[::2] + + self._mock_session_info(mocker, all_uuids) + + response = auth_client.post(self.endpoint, data=dict(select=selected_uuids)) + assert response.status_code == HTTPStatus.OK + table = response.context["table"] + assert isinstance(table, ProcessTable) + + for row in table.data.data: + assert row["checked"] == (row["uuid"] in selected_uuids) diff --git a/tests/test_process_manager_interface.py b/tests/test_process_manager_interface.py new file mode 100644 index 0000000..ceb7955 --- /dev/null +++ b/tests/test_process_manager_interface.py @@ -0,0 +1,13 @@ +from process_manager.process_manager_interface import boot_process + + +def test_boot_process(mocker, dummy_session_data): + """Test the _boot_process function.""" + mock = mocker.patch( + "process_manager.process_manager_interface.get_process_manager_driver" + ) + boot_process("root", dummy_session_data) + mock.assert_called_once() + mock.return_value.dummy_boot.assert_called_once_with( + user="root", **dummy_session_data + ) From c99737f5632712eeb435b2a96bf682b6e88849b8 Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Thu, 3 Oct 2024 22:45:14 +0100 Subject: [PATCH 2/9] Tidy mocks --- tests/conftest.py | 8 ++++++++ tests/process_manager/views/test_action_views.py | 2 +- tests/process_manager/views/test_page_views.py | 15 +++++---------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index d7a7a7c..5429bea 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,3 +40,11 @@ def auth_logs_client(django_user_model) -> Client: def dummy_session_data() -> dict[str, str | int]: """A dictionary of dummy data to populate a dummy session.""" return dict(session_name="sess_name", n_processes=1, sleep=5, n_sleeps=4) + + +@pytest.fixture(autouse=True) +def grpc_mock(mocker): + """Mock out the method that generates gRPC calls to external interfaces.""" + yield mocker.patch( + "process_manager.process_manager_interface.ProcessManagerDriver.send_command_aio" + ) diff --git a/tests/process_manager/views/test_action_views.py b/tests/process_manager/views/test_action_views.py index 1de3a8e..c95d247 100644 --- a/tests/process_manager/views/test_action_views.py +++ b/tests/process_manager/views/test_action_views.py @@ -31,7 +31,7 @@ def test_process_action_invalid_action(self, auth_process_client): @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) def test_process_action_valid_action(self, action, auth_process_client, mocker): """Test process_action view with a valid action.""" - mock = mocker.patch("process_manager.process_manager_interface._process_call") + mock = mocker.patch("process_manager.views.actions.process_call") uuids_ = [str(uuid4()), str(uuid4())] response = auth_process_client.post( self.endpoint, data={"action": action, "select": uuids_} diff --git a/tests/process_manager/views/test_page_views.py b/tests/process_manager/views/test_page_views.py index 7ce3efa..ffe3cff 100644 --- a/tests/process_manager/views/test_page_views.py +++ b/tests/process_manager/views/test_page_views.py @@ -12,27 +12,24 @@ class TestIndexView(LoginRequiredTest): endpoint = reverse("process_manager:index") - def test_index_view_authenticated(self, auth_client, mocker): + def test_index_view_authenticated(self, auth_client): """Test the index view for an authenticated user.""" - mocker.patch("process_manager.process_manager_interface.get_session_info") with assertTemplateUsed(template_name="process_manager/index.html"): response = auth_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK - def test_index_view_admin(self, admin_client, mocker): + def test_index_view_admin(self, admin_client): """Test the index view for an admin user.""" - mocker.patch("process_manager.process_manager_interface.get_session_info") with assertTemplateUsed(template_name="process_manager/index.html"): response = admin_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK assertContains(response, "Boot") - def test_session_messages(self, auth_client, mocker): + def test_session_messages(self, auth_client): """Test the rendering of messages from the user session into the view.""" from django.contrib.sessions.backends.db import SessionStore from django.contrib.sessions.models import Session - mocker.patch("process_manager.process_manager_interface.get_session_info") session = Session.objects.get() message_data = ["message 1", "message 2"] store = SessionStore(session_key=session.session_key) @@ -60,9 +57,7 @@ def test_logs_view_unprivileged(self, auth_client): def test_logs_view_privileged(self, auth_logs_client, mocker): """Test the logs view for a privileged user.""" - mock = mocker.patch( - "process_manager.process_manager_interface._get_process_logs" - ) + mock = mocker.patch("process_manager.views.pages.get_process_logs") with assertTemplateUsed(template_name="process_manager/logs.html"): response = auth_logs_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK @@ -107,7 +102,7 @@ def test_boot_process_post_valid( self, auth_process_client, mocker, dummy_session_data ): """Test the POST request for the BootProcess view.""" - mock = mocker.patch("process_manager.process_manager_interface._boot_process") + mock = mocker.patch("process_manager.views.pages.boot_process") response = auth_process_client.post( reverse("process_manager:boot_process"), data=dummy_session_data ) From b4f7d070140730f5c49f098f2dd524c234269f4d Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Thu, 3 Oct 2024 22:47:00 +0100 Subject: [PATCH 3/9] Shorten test method names --- .../process_manager/views/test_action_views.py | 8 ++++---- tests/process_manager/views/test_page_views.py | 18 ++++++++---------- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/process_manager/views/test_action_views.py b/tests/process_manager/views/test_action_views.py index c95d247..a063535 100644 --- a/tests/process_manager/views/test_action_views.py +++ b/tests/process_manager/views/test_action_views.py @@ -14,13 +14,13 @@ class TestProcessActionView(LoginRequiredTest): endpoint = reverse("process_manager:process_action") - def test_process_action_no_action(self, auth_process_client): + def test_no_action(self, auth_process_client): """Test process_action view with no action provided.""" response = auth_process_client.post(self.endpoint, data={}) assert response.status_code == HTTPStatus.FOUND assert response.url == reverse("process_manager:index") - def test_process_action_invalid_action(self, auth_process_client): + def test_invalid_action(self, auth_process_client): """Test process_action view with an invalid action.""" response = auth_process_client.post( self.endpoint, data={"action": "invalid_action"} @@ -29,7 +29,7 @@ def test_process_action_invalid_action(self, auth_process_client): assert response.url == reverse("process_manager:index") @pytest.mark.parametrize("action", ["kill", "restart", "flush"]) - def test_process_action_valid_action(self, action, auth_process_client, mocker): + def test_valid_action(self, action, auth_process_client, mocker): """Test process_action view with a valid action.""" mock = mocker.patch("process_manager.views.actions.process_call") uuids_ = [str(uuid4()), str(uuid4())] @@ -41,7 +41,7 @@ def test_process_action_valid_action(self, action, auth_process_client, mocker): mock.assert_called_once_with(uuids_, ProcessAction(action)) - def test_process_action_get_unprivileged(self, auth_client): + def test_get_unprivileged(self, auth_client): """Test the GET request for the process_action view (unprivileged).""" response = auth_client.get(reverse("process_manager:boot_process")) assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/tests/process_manager/views/test_page_views.py b/tests/process_manager/views/test_page_views.py index ffe3cff..27f209f 100644 --- a/tests/process_manager/views/test_page_views.py +++ b/tests/process_manager/views/test_page_views.py @@ -12,13 +12,13 @@ class TestIndexView(LoginRequiredTest): endpoint = reverse("process_manager:index") - def test_index_view_authenticated(self, auth_client): + def test_authenticated(self, auth_client): """Test the index view for an authenticated user.""" with assertTemplateUsed(template_name="process_manager/index.html"): response = auth_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK - def test_index_view_admin(self, admin_client): + def test_admin(self, admin_client): """Test the index view for an admin user.""" with assertTemplateUsed(template_name="process_manager/index.html"): response = admin_client.get(self.endpoint) @@ -50,12 +50,12 @@ class TestLogsView(LoginRequiredTest): uuid = uuid4() endpoint = reverse("process_manager:logs", kwargs=dict(uuid=uuid)) - def test_logs_view_unprivileged(self, auth_client): + def test_unprivileged(self, auth_client): """Test the logs view for an unprivileged user.""" response = auth_client.get(self.endpoint) assert response.status_code == HTTPStatus.FORBIDDEN - def test_logs_view_privileged(self, auth_logs_client, mocker): + def test_privileged(self, auth_logs_client, mocker): """Test the logs view for a privileged user.""" mock = mocker.patch("process_manager.views.pages.get_process_logs") with assertTemplateUsed(template_name="process_manager/logs.html"): @@ -72,12 +72,12 @@ class TestBootProcess(LoginRequiredTest): template_name = "process_manager/boot_process.html" endpoint = reverse("process_manager:boot_process") - def test_boot_process_get_unprivileged(self, auth_client): + def test_get_unprivileged(self, auth_client): """Test the GET request for the BootProcess view (unprivileged).""" response = auth_client.get(reverse("process_manager:boot_process")) assert response.status_code == HTTPStatus.FORBIDDEN - def test_boot_process_get_privileged(self, auth_process_client): + def test_get_privileged(self, auth_process_client): """Test the GET request for the BootProcess view (privileged).""" with assertTemplateUsed(template_name=self.template_name): response = auth_process_client.get(reverse("process_manager:boot_process")) @@ -88,7 +88,7 @@ def test_boot_process_get_privileged(self, auth_process_client): response, f'form action="{reverse("process_manager:boot_process")}"' ) - def test_boot_process_post_invalid(self, auth_process_client): + def test_post_invalid(self, auth_process_client): """Test the POST request for the BootProcess view with invalid data.""" with assertTemplateUsed(template_name=self.template_name): response = auth_process_client.post( @@ -98,9 +98,7 @@ def test_boot_process_post_invalid(self, auth_process_client): assert "form" in response.context - def test_boot_process_post_valid( - self, auth_process_client, mocker, dummy_session_data - ): + def test_post_valid(self, auth_process_client, mocker, dummy_session_data): """Test the POST request for the BootProcess view.""" mock = mocker.patch("process_manager.views.pages.boot_process") response = auth_process_client.post( From ebab00e406987ce9640241b1f82ca83a817a2a4f Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Thu, 3 Oct 2024 22:57:58 +0100 Subject: [PATCH 4/9] Refactor tests with PermissionRequiredTest class --- .../process_manager/views/test_action_views.py | 9 ++------- tests/process_manager/views/test_page_views.py | 18 ++++-------------- tests/utils.py | 9 +++++++++ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/process_manager/views/test_action_views.py b/tests/process_manager/views/test_action_views.py index a063535..1290888 100644 --- a/tests/process_manager/views/test_action_views.py +++ b/tests/process_manager/views/test_action_views.py @@ -6,10 +6,10 @@ from process_manager.views.actions import ProcessAction -from ...utils import LoginRequiredTest +from ...utils import PermissionRequiredTest -class TestProcessActionView(LoginRequiredTest): +class TestProcessActionView(PermissionRequiredTest): """Tests for the process_action view.""" endpoint = reverse("process_manager:process_action") @@ -40,8 +40,3 @@ def test_valid_action(self, action, auth_process_client, mocker): assert response.url == reverse("process_manager:index") mock.assert_called_once_with(uuids_, ProcessAction(action)) - - def test_get_unprivileged(self, auth_client): - """Test the GET request for the process_action view (unprivileged).""" - response = auth_client.get(reverse("process_manager:boot_process")) - assert response.status_code == HTTPStatus.FORBIDDEN diff --git a/tests/process_manager/views/test_page_views.py b/tests/process_manager/views/test_page_views.py index 27f209f..828d2a9 100644 --- a/tests/process_manager/views/test_page_views.py +++ b/tests/process_manager/views/test_page_views.py @@ -4,7 +4,7 @@ from django.urls import reverse from pytest_django.asserts import assertContains, assertTemplateUsed -from ...utils import LoginRequiredTest +from ...utils import LoginRequiredTest, PermissionRequiredTest class TestIndexView(LoginRequiredTest): @@ -44,18 +44,13 @@ def test_session_messages(self, auth_client): assert "messages" not in store.load() -class TestLogsView(LoginRequiredTest): +class TestLogsView(PermissionRequiredTest): """Tests for the logs view.""" uuid = uuid4() endpoint = reverse("process_manager:logs", kwargs=dict(uuid=uuid)) - def test_unprivileged(self, auth_client): - """Test the logs view for an unprivileged user.""" - response = auth_client.get(self.endpoint) - assert response.status_code == HTTPStatus.FORBIDDEN - - def test_privileged(self, auth_logs_client, mocker): + def test_get(self, auth_logs_client, mocker): """Test the logs view for a privileged user.""" mock = mocker.patch("process_manager.views.pages.get_process_logs") with assertTemplateUsed(template_name="process_manager/logs.html"): @@ -66,17 +61,12 @@ def test_privileged(self, auth_logs_client, mocker): assert "log_text" in response.context -class TestBootProcess(LoginRequiredTest): +class TestBootProcess(PermissionRequiredTest): """Grouping the tests for the BootProcess view.""" template_name = "process_manager/boot_process.html" endpoint = reverse("process_manager:boot_process") - def test_get_unprivileged(self, auth_client): - """Test the GET request for the BootProcess view (unprivileged).""" - response = auth_client.get(reverse("process_manager:boot_process")) - assert response.status_code == HTTPStatus.FORBIDDEN - def test_get_privileged(self, auth_process_client): """Test the GET request for the BootProcess view (privileged).""" with assertTemplateUsed(template_name=self.template_name): diff --git a/tests/utils.py b/tests/utils.py index 000b0ce..a164d64 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -15,3 +15,12 @@ def test_login_redirect(self, client): assert response.status_code == HTTPStatus.FOUND assertRedirects(response, reverse("main:login") + f"?next={self.endpoint}") + + +class PermissionRequiredTest(LoginRequiredTest): + """Tests for views that require authentication and correct user permissions.""" + + def test_permission_deny(self, auth_client): + """Test that authenticated users missing permissions are blocked.""" + response = auth_client.get(self.endpoint) + assert response.status_code == HTTPStatus.FORBIDDEN From df4f5a2b0bba658e8914a638b91ef2e0049bd7e0 Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Thu, 3 Oct 2024 23:06:39 +0100 Subject: [PATCH 5/9] Tidy user permission fixtures --- tests/conftest.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 5429bea..b24ae6a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -14,26 +14,29 @@ def auth_client(django_user_model) -> Client: return client -@pytest.fixture -def auth_process_client(django_user_model) -> Client: - """Return a authenticated client with modify process privilege.""" - user = django_user_model.objects.create(username="process_user") - permission = Permission.objects.get(codename="can_modify_processes") +def _privileged_user_client(django_user_model, username, permission_name): + user = django_user_model.objects.create(username=username) + permission = Permission.objects.get(codename=permission_name) user.user_permissions.add(permission) client = Client() client.force_login(user) return client +@pytest.fixture +def auth_process_client(django_user_model) -> Client: + """Return a authenticated client with modify process privilege.""" + return _privileged_user_client( + django_user_model, "process_user", "can_modify_processes" + ) + + @pytest.fixture def auth_logs_client(django_user_model) -> Client: """Return a authenticated client with view logs privilege.""" - user = django_user_model.objects.create(username="logs_user") - permission = Permission.objects.get(codename="can_view_process_logs") - user.user_permissions.add(permission) - client = Client() - client.force_login(user) - return client + return _privileged_user_client( + django_user_model, "logs_user", "can_view_process_logs" + ) @pytest.fixture From e802aaa9025c4792aa3988df925af90b0ec9c3f7 Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Tue, 8 Oct 2024 11:11:23 +0100 Subject: [PATCH 6/9] Improve type hints --- drunc_ui/settings/settings.py | 4 ++++ process_manager/process_manager_interface.py | 5 +++-- process_manager/views/pages.py | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/drunc_ui/settings/settings.py b/drunc_ui/settings/settings.py index e36a999..f6e8d31 100644 --- a/drunc_ui/settings/settings.py +++ b/drunc_ui/settings/settings.py @@ -12,6 +12,8 @@ import os from pathlib import Path +import django_stubs_ext + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -151,3 +153,5 @@ CRISPY_TEMPLATE_PACK = "bootstrap5" KAFKA_ADDRESS = os.getenv("KAFKA_ADDRESS", "kafka:9092") + +django_stubs_ext.monkeypatch() diff --git a/process_manager/process_manager_interface.py b/process_manager/process_manager_interface.py index d3fc4e7..3b4c95d 100644 --- a/process_manager/process_manager_interface.py +++ b/process_manager/process_manager_interface.py @@ -1,6 +1,7 @@ """Module providing functions to interact with the drunc process manager.""" import asyncio +from collections.abc import Iterable from enum import Enum from django.conf import settings @@ -41,7 +42,7 @@ class ProcessAction(Enum): FLUSH = "flush" -async def _process_call(uuids: list[str], action: ProcessAction) -> None: +async def _process_call(uuids: Iterable[str], action: ProcessAction) -> None: pmd = get_process_manager_driver() uuids_ = [ProcessUUID(uuid=u) for u in uuids] @@ -58,7 +59,7 @@ async def _process_call(uuids: list[str], action: ProcessAction) -> None: await pmd.flush(query) -def process_call(uuids: list[str], action: ProcessAction) -> None: +def process_call(uuids: Iterable[str], action: ProcessAction) -> None: """Perform an action on a process with a given UUID. Args: diff --git a/process_manager/views/pages.py b/process_manager/views/pages.py index 48b9c75..a2bed55 100644 --- a/process_manager/views/pages.py +++ b/process_manager/views/pages.py @@ -48,7 +48,7 @@ def logs(request: HttpRequest, uuid: uuid.UUID) -> HttpResponse: ) -class BootProcessView(PermissionRequiredMixin, FormView): # type: ignore [type-arg] +class BootProcessView(PermissionRequiredMixin, FormView[BootProcessForm]): """View for the BootProcess form.""" template_name = "process_manager/boot_process.html" From 55ae84eb75848a39ec6f5a9bd85bc1e109052176 Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Tue, 8 Oct 2024 11:13:33 +0100 Subject: [PATCH 7/9] Add comment on error handling of enum value in process_action view --- process_manager/views/actions.py | 1 + 1 file changed, 1 insertion(+) diff --git a/process_manager/views/actions.py b/process_manager/views/actions.py index eb41745..a54fb77 100644 --- a/process_manager/views/actions.py +++ b/process_manager/views/actions.py @@ -24,6 +24,7 @@ def process_action(request: HttpRequest) -> HttpResponse: action = request.POST.get("action", "") action_enum = ProcessAction(action.lower()) except ValueError: + # action.lower() is not a valid enum value return HttpResponseRedirect(reverse("process_manager:index")) if uuids_ := request.POST.getlist("select"): From 995e71905c3fd84c65bab2aa0237ea6e0a7f1166 Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Tue, 8 Oct 2024 11:44:28 +0100 Subject: [PATCH 8/9] Add django-stubs-ext as a non-dev dependency --- poetry.lock | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 9248404..06669ac 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1365,4 +1365,4 @@ brotli = ["brotli"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "0a4b565353601196340ecd3d1df522108d10a47b047d377332f62ce4bdeb33c6" +content-hash = "983b5bf7cc34ce2f31a452917c254e7f4c87ddcdccc0ffc7a354c124d96c220d" diff --git a/pyproject.toml b/pyproject.toml index fb0641b..5f170b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ django-bootstrap5 = "^24.3" pytest-asyncio = "^0.24.0" django-crispy-forms = "^2.3" crispy-bootstrap5 = "^2024.10" +django-stubs-ext = "^5.1.0" [tool.poetry.group.dev.dependencies] pytest = "^8.3" From 864ca64e03f99de35b7a0231f6d6f0497d1b748a Mon Sep 17 00:00:00 2001 From: Christopher Cave-Ayland Date: Tue, 8 Oct 2024 15:12:31 +0100 Subject: [PATCH 9/9] Remove test_admin from TestIndexView --- tests/process_manager/views/test_page_views.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/tests/process_manager/views/test_page_views.py b/tests/process_manager/views/test_page_views.py index 828d2a9..0ad5da7 100644 --- a/tests/process_manager/views/test_page_views.py +++ b/tests/process_manager/views/test_page_views.py @@ -18,13 +18,6 @@ def test_authenticated(self, auth_client): response = auth_client.get(self.endpoint) assert response.status_code == HTTPStatus.OK - def test_admin(self, admin_client): - """Test the index view for an admin user.""" - with assertTemplateUsed(template_name="process_manager/index.html"): - response = admin_client.get(self.endpoint) - assert response.status_code == HTTPStatus.OK - assertContains(response, "Boot") - def test_session_messages(self, auth_client): """Test the rendering of messages from the user session into the view.""" from django.contrib.sessions.backends.db import SessionStore