# Copyright 2022 The Debusine Developers
# See the AUTHORS file at the top-level directory of this distribution
#
# This file is part of Debusine. It is subject to the license terms
# in the LICENSE file found in the top-level directory of this
# distribution. No part of Debusine, including this file, may be copied,
# modified, propagated, or distributed except according to the terms
# contained in the LICENSE file.

"""Tests for the work request views."""

import textwrap
from datetime import timedelta
from types import GeneratorType
from typing import ClassVar

import yaml
from django.contrib.auth import get_user_model
from django.contrib.messages import constants as messages_constants
from django.contrib.messages import get_messages
from django.contrib.messages.storage.base import Message
from django.core.exceptions import ValidationError
from django.core.validators import ProhibitNullCharactersValidator
from django.urls import reverse
from django.utils import timezone
from lxml import html
from rest_framework import status

from debusine.artifacts import LintianArtifact
from debusine.db.context import context
from debusine.db.models import (
    Artifact,
    TaskDatabase,
    User,
    WorkRequest,
    WorkflowTemplate,
    Workspace,
)
from debusine.server.workflows.models import (
    WorkRequestManualUnblockAction,
    WorkRequestManualUnblockData,
    WorkRequestManualUnblockLog,
)
from debusine.tasks import Lintian
from debusine.tasks.models import TaskTypes
from debusine.tasks.tests.test_simplesystemimagebuild import (
    SimpleSystemImageBuildTests,
)
from debusine.test.django import TestCase
from debusine.web.views import ui_shortcuts
from debusine.web.views.lintian import LintianView
from debusine.web.views.tests.test_views import _sort_table_handle
from debusine.web.views.tests.utils import ViewTestMixin
from debusine.web.views.work_request import (
    WorkRequestDetailView,
    WorkRequestListView,
)


class WorkRequestDetailViewTests(ViewTestMixin, TestCase):
    """Tests for WorkRequestDetailView class."""

    workspace: ClassVar[Workspace]
    source: ClassVar[Artifact]
    work_request: ClassVar[WorkRequest]

    @classmethod
    @context.disable_permission_checks()
    def setUpTestData(cls):
        """Set up common objects."""
        super().setUpTestData()
        cls.workspace = cls.playground.create_workspace(
            name="Public", public=True
        )
        started_at = timezone.now()
        duration = 73
        completed_at = started_at + timedelta(seconds=duration)

        environment_item = cls.playground.create_debian_environment(
            workspace=cls.workspace
        )
        assert environment_item.artifact is not None
        environment = environment_item.artifact

        cls.source = cls.playground.create_source_artifact(
            workspace=cls.workspace
        )
        cls.work_request = cls.playground.create_sbuild_work_request(
            source=cls.source,
            architecture="all",
            environment=environment,
            workspace=cls.workspace,
        )
        cls.playground.compute_dynamic_data(cls.work_request)
        cls.work_request.mark_running()
        cls.work_request.assign_worker(cls.playground.create_worker())
        cls.work_request.mark_completed(WorkRequest.Results.SUCCESS)
        cls.work_request.started_at = started_at
        cls.work_request.completed_at = completed_at
        cls.work_request.save()

    def test_get_work_request(self):
        """View detail return work request information."""
        artifact, _ = self.create_artifact()
        artifact.created_by_work_request = self.work_request
        artifact.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        sidebar = response.context["sidebar_items"]
        self.assertEqual(sidebar[0].value, "build a package")
        self.assertEqual(
            sidebar[0].detail,
            f'{self.work_request.task_type} {self.work_request.task_name} task',
        )
        self.assertEqual(sidebar[1].content, "-")
        self.assertEqual(
            sidebar[2].content,
            '<span class="badge text-bg-primary">Completed</span>'
            ' <span class="badge text-bg-success">Success</span>',
        )
        self.assertEqual(sidebar[3].content, self.work_request.workspace.name)
        self.assertEqual(sidebar[4].content, str(self.work_request.created_by))
        self.assertEqual(sidebar[6].content, self.work_request.worker.name)
        self.assertEqual(sidebar[7].content, "0\xa0minutes")
        self.assertEqual(sidebar[8].content, "1\xa0minute")
        self.assertEqual(sidebar[9].content, "never")

        # Check artifact list
        tbody = tree.xpath("//table[@id='artifacts']")[0].tbody[1]
        self.assertEqual(len(tbody.tr), 2)
        tr = tbody.tr[1]
        self.assertTextContentEqual(tr.td[0], "Artifact")
        self.assertEqual(
            tr.td[1].a.get("href"),
            reverse("artifacts:detail", kwargs={"artifact_id": artifact.id}),
        )
        self.assertTextContentEqual(tr.td[1].a, "debusine:test")

        # Not in a workflow
        self.assertNotContains(
            response, "<h2>Workflow information</h2>", html=True
        )

    def test_get_work_request_private_workspace_unauthenticated(self):
        """Work requests in private workspaces 403 to the unauthenticated."""
        with context.disable_permission_checks():
            private_workspace = self.playground.create_workspace(name="Private")
        self.work_request.workspace = private_workspace
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertContains(
            response,
            "You need to be authenticated to view this Work Request",
            status_code=status.HTTP_403_FORBIDDEN,
        )

    def test_get_work_request_private_workspace_authenticated(self):
        """Work requests in private workspaces 200 to the authenticated."""
        with context.disable_permission_checks():
            private_workspace = self.playground.create_workspace(name="Private")
        self.work_request.workspace = private_workspace
        self.work_request.save()

        self.client.force_login(self.playground.get_default_user())

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_get_work_request_no_retry_if_logged_out(self):
        """No retry link if the user is logged out."""
        self.work_request.task_name = "noop"
        self.work_request.task_data = {}
        self.work_request.dynamic_task_data = {}
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.context["main_ui_shortcuts"], [])

    def test_get_work_request_no_retry_if_successful(self):
        """No retry link if the work request is successful."""
        self.work_request.task_name = "noop"
        self.work_request.task_data = {}
        self.work_request.dynamic_task_data = {}
        self.work_request.save()

        self.client.force_login(self.playground.get_default_user())
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(response.context["main_ui_shortcuts"], [])

    def test_get_work_request_can_retry(self):
        """Show the retry link if a work request can be retried."""
        self.work_request.task_name = "noop"
        self.work_request.task_data = {}
        self.work_request.dynamic_task_data = {}
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.save()

        self.client.force_login(self.playground.get_default_user())
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertEqual(
            response.context["main_ui_shortcuts"],
            [
                ui_shortcuts.create_work_request_retry(self.work_request),
            ],
        )

    def test_get_work_request_superseded(self):
        """Check that superseding/superseded links show up."""
        self.work_request.status = WorkRequest.Statuses.ABORTED
        self.work_request.result = WorkRequest.Results.NONE
        self.work_request.save()

        wr_new = self.work_request.retry()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        sidebar = response.context["sidebar_items"]
        self.assertEqual(sidebar[1].value, str(wr_new))
        self.assertEqual(sidebar[1].label, "Superseded by")
        self.assertEqual(
            sidebar[1].url,
            reverse("work-requests:detail", kwargs={"pk": wr_new.pk}),
        )

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": wr_new.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        sidebar = response.context["sidebar_items"]
        self.assertEqual(sidebar[1].value, str(self.work_request))
        self.assertEqual(sidebar[1].label, "Supersedes")
        self.assertEqual(
            sidebar[1].url,
            reverse(
                "work-requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )

    def test_get_work_request_running(self):
        """Check that elapsed execution time displays."""
        self.work_request.status = WorkRequest.Statuses.RUNNING
        self.work_request.result = WorkRequest.Results.NONE
        self.work_request.started_at = timezone.now() - timedelta(minutes=10)
        self.work_request.completed_at = None
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        sidebar = response.context["sidebar_items"]
        self.assertEqual(
            [item.content for item in sidebar if item.label == "Duration"],
            ["10\xa0minutes"],
        )

    def make_work_request_lintian(self, work_request: WorkRequest):
        """Change work_request to "lintian", create source artifact."""
        work_request.task_name = "lintian"
        source_artifact = self.playground.create_source_artifact()
        work_request.task_data = {
            "input": {
                "source_artifact": source_artifact.id,
                "binary_artifacts": [],
            }
        }
        work_request.dynamic_task_data = None
        work_request.save()
        work_request.artifact_set.add(self.create_lintian_source_artifact())

    def test_use_specific_plugin(self):
        """Test usage of a plugin instead of generic view."""
        self.make_work_request_lintian(self.work_request)

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )

        # WorkRequest_get_template_names() return the correct template name
        self.assertIn(LintianView.template_name, response.template_name)

        # The LintianView().get_context_data() is in the response
        self.assertDictContainsAll(
            response.context_data,
            LintianView().get_context_data(self.work_request),
        )

    def create_lintian_source_artifact(self) -> Artifact:
        """
        Create a Lintian source artifact result.

        Contains lintian.txt file.
        """
        artifact, _ = self.create_artifact(
            paths=[Lintian.CAPTURE_OUTPUT_FILENAME],
            create_files=True,
            category=LintianArtifact._category,
            data={
                "summary": {
                    "package_filename": {"hello": "hello.dsc"},
                    "tags_count_by_severity": {},
                    "tags_found": [],
                    "overridden_tags_found": [],
                    "lintian_version": "2.117.0",
                    "distribution": "sid",
                },
            },
        )
        artifact.created_by_work_request = self.work_request
        artifact.save()
        return artifact

    def test_do_not_use_available_plugin_use_default(self):
        """Request to detail with view=generic: do not use plugin."""
        self.make_work_request_lintian(self.work_request)

        path = reverse(
            "work-requests:detail", kwargs={"pk": self.work_request.id}
        )

        response = self.client.get(path + "?view=generic")

        self.assertIn(
            WorkRequestDetailView.default_template_name, response.template_name
        )

        with self.assertRaises(AssertionError):
            # The context_data does not contain the specific Lintian
            # work request plugin data
            self.assertDictContainsAll(
                response.context_data,
                LintianView().get_context_data(self.work_request),
            )

        specialized_view_path = path
        self.assertContains(
            response,
            f'<p><a href="{specialized_view_path}">'
            f'{self.work_request.task_name} view</a></p>',
            html=True,
        )

    def test_do_not_use_available_plugin_invalid_task_data(self):
        """If task data is invalid, do not use plugin."""
        self.make_work_request_lintian(self.work_request)
        self.work_request.task_data["input"]["binary_artifacts_ids"] = 1
        self.work_request.dynamic_task_data = {}
        self.work_request.save()

        with self.assertRaises(ValidationError) as cm:
            self.work_request.full_clean()
        validation_error = str(cm.exception)

        path = reverse(
            "work-requests:detail", kwargs={"pk": self.work_request.id}
        )

        response = self.client.get(path)

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        self.assertIn(
            WorkRequestDetailView.default_template_name, response.template_name
        )

        # The context_data does not contain the specific Lintian work
        # request plugin data
        self.assertNotIn("lintian_txt_path", response.context_data)

        # The page does not link to the specialized view; instead, it shows
        # the validation error.
        specialized_view_path = path
        self.assertNotContains(
            response,
            f'<p><a href="{specialized_view_path}">'
            f'{self.work_request.task_name} view</a></p>',
            html=True,
        )
        self.assertContains(
            response,
            "<h2>Validation error</h2>"
            f"<pre><code>{validation_error}</code></pre>",
            html=True,
        )

    def test_multi_line_string(self):
        """Multi-line strings are rendered using the literal style."""
        self.work_request.task_name = "mmdebstrap"
        self.work_request.task_data = {
            "bootstrap_options": {"architecture": "amd64"},
            "bootstrap_repositories": [
                {"mirror": "https://deb.debian.org/debian", "suite": "bookworm"}
            ],
            "customization_script": "multi-line\nstring\n",
        }
        self.work_request.dynamic_task_data = {}
        self.work_request.save()

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": self.work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)
        card = tree.xpath("//div[@id='metadata']")[0]

        self.assertTextContentEqual(
            card.div[1],
            """\
            bootstrap_options:
              architecture: amd64
            bootstrap_repositories:
            - mirror: https://deb.debian.org/debian
              suite: bookworm
            customization_script: |
              multi-line
              string
            """,
        )

    def test_workflow_root(self):
        """A workflow root shows information on its descendants."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=self.playground.get_default_user(),
        )
        child_template = WorkflowTemplate.objects.create(
            name="child",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        child = WorkRequest.objects.create_workflow(
            template=child_template,
            data={},
            created_by=self.playground.get_default_user(),
            parent=root,
        )
        grandchildren = []
        for i in range(2):
            wr = self.playground.create_work_request(
                parent=child,
                workflow_data_json={
                    "display_name": f"Lintian {i + 1}",
                    "step": f"lintian{i + 1}",
                },
            )
            wr.add_dependency(child)
            self.make_work_request_lintian(wr)
            grandchildren.append(wr)

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": root.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        def wr_url(pk: int) -> str:
            return reverse("work-requests:detail", kwargs={"pk": pk})

        root_link = f'<a href="{wr_url(root.id)}">noop</a>'
        child_link = f'<a href="{wr_url(child.id)}">noop</a>'
        grandchild_links = [
            f'<a href="{wr_url(grandchild.id)}">'
            f'{grandchild.workflow_data.display_name}</a>'
            for grandchild in grandchildren
        ]
        pending = '<span class="badge text-bg-secondary">Pending</span>'
        blocked = '<span class="badge text-bg-secondary">Blocked</span>'
        self.assertContains(
            response,
            textwrap.dedent(
                f"""
                <h2>Workflow information</h2>
                <ul>
                    <li>
                        <details>
                            <summary>{root_link} ({pending})</summary>
                            <ul>
                                <li>
                                    <details>
                                        <summary>
                                            {child_link} ({pending})
                                        </summary>
                                        <ul>
                                            <li>
                                                {grandchild_links[0]}
                                                ({blocked})
                                            </li>
                                            <li>
                                                {grandchild_links[1]}
                                                ({blocked})
                                            </li>
                                        </ul>
                                    </details>
                                </li>
                            </ul>
                        </details>
                    </li>
                </ul>
                """
            ),
            html=True,
        )

    def test_workflow_child(self):
        """A workflow child shows information on its root/parent/descendants."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=self.playground.get_default_user(),
        )
        child_template = WorkflowTemplate.objects.create(
            name="child",
            workspace=self.workspace,
            task_name="noop",
            task_data={},
        )
        child = WorkRequest.objects.create_workflow(
            template=child_template,
            data={},
            created_by=self.playground.get_default_user(),
            parent=root,
        )
        grandchildren = []
        for i in range(2):
            wr = self.playground.create_work_request(
                parent=child,
                workflow_data_json={
                    "display_name": f"Lintian {i + 1}",
                    "step": f"lintian{i + 1}",
                },
            )
            wr.add_dependency(child)
            self.make_work_request_lintian(wr)
            grandchildren.append(wr)

        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": child.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)

        def wr_url(pk: int) -> str:
            return reverse("work-requests:detail", kwargs={"pk": pk})

        root_link = f'<a href="{wr_url(root.id)}">noop</a>'
        child_link = f'<a href="{wr_url(child.id)}">noop</a>'
        grandchild_links = [
            f'<a href="{wr_url(grandchild.id)}">'
            f'{grandchild.workflow_data.display_name}</a>'
            for grandchild in grandchildren
        ]
        pending = '<span class="badge text-bg-secondary">Pending</span>'
        blocked = '<span class="badge text-bg-secondary">Blocked</span>'
        self.assertContains(
            response,
            textwrap.dedent(
                f"""
                <h2>Workflow information</h2>
                <ul>
                    <li>Root: {root_link}</li>
                    <li>Parent: {root_link}</li>
                    <li>
                        <details>
                            <summary>{child_link} ({pending})</summary>
                            <ul>
                                <li>{grandchild_links[0]} ({blocked})</li>
                                <li>{grandchild_links[1]} ({blocked})</li>
                            </ul>
                        </details>
                    </li>
                </ul>
                """
            ),
            html=True,
        )

    def test_workflow_manual_unblock_log(self) -> None:
        """A work request's manual unblock log is rendered if present."""
        user = self.playground.get_default_user()
        self.playground.add_user_permission(
            user, WorkRequest, "change_workrequest"
        )
        template = self.playground.create_workflow_template("test", "noop")
        root = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=self.playground.get_default_user(),
            status=WorkRequest.Statuses.RUNNING,
        )
        work_request = WorkRequest.objects.create_synchronization_point(
            parent=root, step="test", status=WorkRequest.Statuses.BLOCKED
        )
        work_request.unblock_strategy = WorkRequest.UnblockStrategy.MANUAL
        workflow_data = work_request.workflow_data
        workflow_data.manual_unblock = WorkRequestManualUnblockData(
            log=[
                WorkRequestManualUnblockLog(
                    user_id=self.playground.get_default_user().id,
                    timestamp=timezone.now(),
                    notes="LGTM",
                    action=WorkRequestManualUnblockAction.ACCEPT,
                )
            ]
        )
        work_request.workflow_data = workflow_data
        work_request.save()

        self.client.force_login(user)
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        tbody = tree.xpath("//table[@id='review-log']")[0].tbody[0]
        self.assertEqual(len(tbody.tr), 1)
        self.assertEqual(
            tbody.tr.td[1], self.playground.get_default_user().username
        )
        self.assertHTMLEqual(
            html.tostring(tbody.tr.td[2], encoding="unicode"),
            '<td>'
            '<span class="bi bi-check2 text-success" title="Accept"></span>'
            '</td>',
        )
        self.assertTextContentEqual(tbody.tr.td[3], "LGTM")

        form = tree.xpath("//form[@id='manual-unblock-form']")[0]
        self.assertEqual(
            form.get("action"),
            reverse("work_requests:unblock", kwargs={"pk": work_request.id}),
        )
        submit_buttons = [
            tag for tag in form.input if tag.get("type") == "submit"
        ]
        self.assertEqual(
            ["Accept", "Reject", "Record notes only"],
            [tag.get("value") for tag in submit_buttons],
        )

    def test_external_debsign_requires_signature(self) -> None:
        """A blocked `ExternalDebsign` work request shows a prompt."""
        user = self.playground.get_default_user()
        work_request = self.playground.create_work_request(
            task_type=TaskTypes.WAIT,
            task_name="externaldebsign",
            created_by=user,
            status=WorkRequest.Statuses.BLOCKED,
        )

        self.client.force_login(user)
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)
        requires_signature = tree.xpath("//div[@id='requires-signature']")[0]
        self.assertTextContentEqual(
            requires_signature.div[0], "Waiting for signature"
        )
        self.assertHTMLContentsEquivalent(
            requires_signature.div[1],
            f'<div class="card-body">'
            f"Run <code>debusine provide-signature {work_request.id}</code> "
            f"to sign this request."
            f"</div>",
        )

    def test_external_debsign_wrong_user(self) -> None:
        """A blocked `ExternalDebsign` only shows a prompt to its creator."""
        work_request = self.playground.create_work_request(
            task_type=TaskTypes.WAIT,
            task_name="externaldebsign",
            status=WorkRequest.Statuses.BLOCKED,
        )
        other_user = get_user_model().objects.create_user(
            username="another", email="another@example.org"
        )

        self.client.force_login(other_user)
        response = self.client.get(
            reverse("work-requests:detail", kwargs={"pk": work_request.id})
        )

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)
        self.assertEqual(tree.xpath("//div[@id='requires-signature']"), [])


class WorkRequestListViewTests(ViewTestMixin, TestCase):
    """Tests for WorkRequestListView class."""

    only_public_work_requests_message = (
        "Not authenticated. Only work requests "
        "in public workspaces are listed."
    )

    @classmethod
    @context.disable_permission_checks()
    def setUpTestData(cls):
        """Create workspaces used in the tests."""
        super().setUpTestData()
        cls.private_workspace = cls.playground.create_workspace(name="Private")
        cls.public_workspace = cls.playground.create_workspace(
            name="Public", public=True
        )

    def test_get_no_work_request(self):
        """No work requests in the server: 'No work requests' in response."""
        response = self.client.get(reverse("work-requests:list"))

        self.assertEqual(response.status_code, status.HTTP_200_OK)
        self.assertContains(response, "No work requests.", html=True)

    def test_get_work_requests_all_authenticated_request(self):
        """Two work requests: all information appear in the response."""
        public_work_request = self.playground.create_work_request(
            task_name="noop",
            result=WorkRequest.Results.SUCCESS,
            workspace=self.public_workspace,
        )
        private_work_request = self.playground.create_work_request(
            task_name="noop", workspace=self.private_workspace
        )

        user = get_user_model().objects.create_user(
            username="testuser", password="testpassword", email="testemail"
        )
        self.client.force_login(user)

        response = self.client.get(reverse("work-requests:list"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)
        tree = self.assertHTMLValid(response)

        # For all the workspaces does not display "Workspace: "
        self.assertNotContains(response, "Workspace: ")

        self.assertQuerySetEqual(
            response.context_data["object_list"],
            WorkRequest.objects.all().order_by("-created_at"),
        )

        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], private_work_request)
        self.assertWorkRequestRow(table.tbody.tr[1], public_work_request)
        self.assertEqual(len(table.tbody.tr), 2)

        self.assertNotContains(response, self.only_public_work_requests_message)

        # the view have the handle to sort the table
        self.assertContains(response, _sort_table_handle, html=True)

    def test_get_work_requests_public_not_authenticated(self):
        """One work request: public one only."""
        public_work_request = self.playground.create_work_request(
            task_name="noop",
            result=WorkRequest.Results.SUCCESS,
            workspace=self.public_workspace,
        )
        self.playground.create_work_request(
            task_name="noop", workspace=self.private_workspace
        )

        response = self.client.get(reverse("work-requests:list"))
        tree = self.assertHTMLValid(response)

        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], public_work_request)
        self.assertEqual(len(table.tbody.tr), 1)

        self.assertContains(response, self.only_public_work_requests_message)

    def test_get_work_requests_filter_by_bad_workspace(self):
        """Work request is created and filtered with an invalid name."""
        self.playground.create_work_request(task_name="noop")

        response = self.client.get(
            reverse("work-requests:list") + "?workspace=does-not-exist"
        )
        self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

    def test_get_work_requests_filter_by_good_workspace(self):
        """Work request is created and filtered with a valid name."""
        self.playground.create_work_request(task_name="noop")

        response = self.client.get(
            reverse("work-requests:list") + "?workspace=Public"
        )
        self.assertEqual(response.status_code, status.HTTP_200_OK)

    def test_get_work_requests_exclude_internal(self):
        """The list excludes INTERNAL work requests."""
        template = WorkflowTemplate.objects.create(
            name="test",
            workspace=self.public_workspace,
            task_name="noop",
            task_data={},
        )
        root = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=self.playground.get_default_user(),
        )
        WorkRequest.objects.create_synchronization_point(
            parent=root, step="test"
        )

        response = self.client.get(reverse("work-requests:list"))
        self.assertEqual(response.status_code, status.HTTP_200_OK)

        tree = self.assertHTMLValid(response)
        self.assertQuerySetEqual(response.context_data["object_list"], [root])

        table = tree.xpath("//table[@id='work_request-list']")[0]
        self.assertWorkRequestRow(table.tbody.tr[0], root)
        self.assertEqual(len(table.tbody.tr), 1)

    def test_pagination(self):
        """Pagination is set up and rendered by the template."""
        self.playground.create_work_request(
            task_name="noop", workspace=self.public_workspace
        )

        self.assertGreaterEqual(WorkRequestListView.paginate_by, 10)
        response = self.client.get(reverse("work-requests:list"))
        self.assertContains(response, '<nav aria-label="pagination">')
        self.assertIsInstance(
            response.context["elided_page_range"], GeneratorType
        )

    def test_sorting(self):
        """Test sorting."""
        for field in ["id", "created_at", "task_name", "status", "result"]:
            for asc in ["0", "1"]:
                response = self.client.get(
                    reverse("work-requests:list") + f"?order={field}&asc={asc}"
                )

            self.assertEqual(response.context["order"], field)
            self.assertEqual(response.context["asc"], asc)

    def test_sorting_invalid_field(self):
        """Test sorting by a non-valid field: sorted by id."""
        response = self.client.get(
            reverse("work-requests:list") + "?order=something"
        )

        self.assertEqual(response.context["order"], "created_at")
        self.assertEqual(response.context["asc"], "0")

    def test_sorting_field_is_valid(self):
        """Test sorting with asc=0: valid order and asc."""
        response = self.client.get(
            reverse("work-requests:list") + "?order=id&asc=0"
        )
        self.assertEqual(response.context["order"], "id")
        self.assertEqual(response.context["asc"], "0")

    def test_filter_by_status(self):
        """Test get_queryset() with status=XX returns expected WorkRequests."""
        work_requests = {
            "pending": self.playground.create_work_request(
                status=WorkRequest.Statuses.PENDING,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "running": self.playground.create_work_request(
                status=WorkRequest.Statuses.RUNNING,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "completed": self.playground.create_work_request(
                status=WorkRequest.Statuses.COMPLETED,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "aborted": self.playground.create_work_request(
                status=WorkRequest.Statuses.ABORTED,
                task_name="noop",
                workspace=self.public_workspace,
            ),
            "blocked": self.playground.create_work_request(
                status=WorkRequest.Statuses.BLOCKED,
                task_name="noop",
                workspace=self.public_workspace,
            ),
        }

        for work_request_status, work_request in work_requests.items():
            with self.subTest(work_request_status):
                response = self.client.get(
                    reverse("work-requests:list")
                    + f"?status={work_request_status}"
                )
                self.assertQuerySetEqual(
                    response.context_data["work_request_list"], [work_request]
                )

    def test_filter_by_architecture_invalid_status(self):
        """
        Test get_queryset() with invalid status returns all work requests.

        It also adds a Message to the page.
        """
        work_request = self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="noop",
            workspace=self.public_workspace,
        )

        response = self.client.get(
            reverse("work-requests:list") + "?status=invalid"
        )

        self.assertEqual(
            list(response.context["messages"]),
            [
                Message(
                    messages_constants.WARNING,
                    'Invalid "status" parameter, ignoring it',
                    extra_tags="",
                )
            ],
        )

        self.assertQuerySetEqual(
            response.context_data["work_request_list"], [work_request]
        )

    def test_get_queryset_filter_by_architecture_wrong_status(self):
        """Test get_queryset() filters by architecture only for pending."""
        work_request = self.playground.create_work_request(
            status=WorkRequest.Statuses.RUNNING,
            task_name="noop",
            workspace=self.public_workspace,
        )

        response = self.client.get(
            reverse("work-requests:list") + "?status=running&arch=amd64"
        )

        self.assertEqual(
            list(response.context["messages"]),
            [
                Message(
                    messages_constants.WARNING,
                    'Filter by architecture is only supported when '
                    'also filtering by "status=pending", ignoring architecture'
                    'filtering',
                    extra_tags="",
                )
            ],
        )

        self.assertQuerySetEqual(
            response.context_data["work_request_list"], [work_request]
        )

    def test_filter_by_architecture(self):
        """Test filtering by architecture."""
        # Create two amd64 work requests
        work_request_amd64_1 = self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="noop",
            workspace=self.public_workspace,
            task_data={"host_architecture": "amd64"},
        )
        work_request_amd64_2 = self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="simplesystemimagebuild",
            workspace=self.public_workspace,
            task_data=SimpleSystemImageBuildTests.SAMPLE_TASK_DATA,
        )
        # Assert architecture is as expected
        self.assertEqual(
            work_request_amd64_2.get_task().host_architecture(), "amd64"
        )

        # Create two i386 work requests
        self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="noop",
            workspace=self.public_workspace,
            task_data={"host_architecture": "i386"},
        )

        task_data_i386 = SimpleSystemImageBuildTests.SAMPLE_TASK_DATA.copy()
        task_data_i386["bootstrap_options"]["architecture"] = "i386"
        self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="simplesystemimagebuild",
            workspace=self.public_workspace,
            task_data=task_data_i386,
        )

        # Create a work request for which "WorkRequest.get_task()" raise
        # TaskConfigError.
        self.playground.create_work_request(
            status=WorkRequest.Statuses.PENDING,
            task_name="sbuild",
            workspace=self.public_workspace,
        )

        response = self.client.get(
            reverse("work-requests:list") + "?status=pending&arch=amd64"
        )

        self.assertQuerySetEqual(
            response.context_data["work_request_list"],
            [work_request_amd64_2, work_request_amd64_1],
        )


class WorkRequestCreateViewTests(TestCase):
    """Tests for WorkRequestCreateView."""

    user: ClassVar[User]
    superuser: ClassVar[User]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = get_user_model().objects.create_user(
            username="testuser", password="testpass"
        )
        cls.superuser = get_user_model().objects.create_superuser(
            username="testsuperuser",
            password="testsuperpass",
            email="superuser@mail.none",
        )

    def test_create_work_request_permission_denied(self):
        """A non-authenticated request cannot get the form (or post)."""
        for method in [self.client.get, self.client.post]:
            with self.subTest(method):
                response = method(reverse("work_requests:create"))
                self.assertContains(
                    response,
                    "You need to be authenticated to create a Work Request",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

    def test_create_work_request(self):
        """Post to "work_requests:create" to create a work request."""
        self.client.force_login(self.user)
        workspace = Workspace.objects.earliest("id")
        source_artifact, _ = self.playground.create_artifact()
        name = "sbuild"

        task_data_yaml = textwrap.dedent(
            f"""
        build_components:
        - any
        - all
        backend: schroot
        distribution: stable
        host_architecture: amd64
        input:
          source_artifact: {source_artifact.id}
        """  # noqa: E501
        )

        self.assertEqual(WorkRequest.objects.count(), 0)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )

        # The work request got created
        work_request = WorkRequest.objects.latest("id")
        self.assertIsNotNone(work_request.id)

        # and has the user assigned
        self.assertEqual(work_request.created_by, self.user)

        # the browser got redirected to the work_requests:detail
        self.assertRedirects(
            response,
            reverse("work_requests:detail", kwargs={"pk": work_request.id}),
        )

    def test_create_work_request_user_rights(self):
        """Test "work_requests:create" depending on user rights."""
        self.client.force_login(self.superuser)
        workspace = Workspace.objects.earliest("id")
        name = "servernoop"
        task_data_yaml = textwrap.dedent(
            """
        """  # noqa: E501
        )

        self.assertEqual(WorkRequest.objects.count(), 0)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )

        # The work request got created
        work_request = WorkRequest.objects.latest("id")
        self.assertIsNotNone(work_request.id)

        # and has the user assigned
        self.assertEqual(work_request.created_by, self.superuser)

        # the browser got redirected to the work_requests:detail
        self.assertRedirects(
            response,
            reverse("work_requests:detail", kwargs={"pk": work_request.id}),
        )

        self.client.force_login(self.user)
        workspace = Workspace.objects.earliest("id")
        name = "servernoop"
        task_data_yaml = textwrap.dedent(
            """
        """  # noqa: E501
        )

        self.assertEqual(WorkRequest.objects.count(), 1)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )
        self.assertIn(
            "Select a valid choice. servernoop is not one",
            response.content.decode(),
        )

    def test_create_work_request_invalid_task_data(self):
        """Test form invalid due to some_method raising a ValidationError."""
        self.client.force_login(self.user)
        workspace = Workspace.objects.earliest("id")
        name = "sbuild"
        source_artifact, _ = self.playground.create_artifact()

        task_data_yaml = textwrap.dedent(
            """
            build_components:
            - any
            - all
            host_architecture: amd64
            input:
                source_artifact: 536
            environment: does-not-exist/match:codename=trixie:variant=sbuild
            """
        )

        self.assertEqual(WorkRequest.objects.count(), 0)

        response = self.client.post(
            reverse("work_requests:create"),
            {
                "workspace": workspace.id,
                "task_name": name,
                "task_data": task_data_yaml,
            },
        )

        # The work request is not created
        self.assertEqual(WorkRequest.objects.count(), 0)

        # The form displays the error on the task_data field
        self.assertContains(response, "Invalid task data")

        # Get the error message
        try:
            work_request = WorkRequest.objects.create(
                task_name=name,
                workspace=workspace,
                task_data=yaml.safe_load(task_data_yaml),
                created_by=self.user,
            )
            work_request.get_task().compute_dynamic_data(
                TaskDatabase(work_request)
            )
        except Exception as exc:
            error_message = f"Invalid task data: {exc}"

        self.assertFormError(
            response.context["form"], "task_data", error_message
        )


class WorkRequestRetryViewTests(TestCase):
    """Tests for WorkRequestRetryView."""

    user: ClassVar[User]
    workspace: ClassVar[Workspace]
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls):
        """Initialize class data."""
        super().setUpTestData()
        cls.user = cls.playground.get_default_user()
        cls.workspace = Workspace.objects.earliest("id")
        cls.work_request = cls.playground.create_work_request(
            workspace=cls.workspace,
            task_name="noop",
            task_data={},
            status=WorkRequest.Statuses.ABORTED,
        )

    def test_retry_not_logged_in(self):
        """A non-authenticated request cannot retry."""
        for method in [self.client.get, self.client.post]:
            with self.subTest(method):
                response = method(
                    reverse(
                        "work_requests:retry",
                        kwargs={"pk": self.work_request.pk},
                    )
                )
                self.assertContains(
                    response,
                    "You need to be authenticated to retry a Work Request",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

    def test_retry_invalid(self):
        """Try retrying a work request that cannot be retried."""
        self.work_request.status = WorkRequest.Statuses.COMPLETED
        self.work_request.result = WorkRequest.Results.SUCCESS
        self.work_request.save()

        self.client.force_login(self.user)
        response = self.client.post(
            reverse(
                "work_requests:retry",
                kwargs={"pk": self.work_request.pk},
            )
        )
        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail",
                kwargs={"pk": self.work_request.pk},
            ),
        )
        messages = list(get_messages(response.wsgi_request))
        self.assertEqual(len(messages), 1)
        self.assertEqual(
            messages[0].message,
            "Cannot retry: Only aborted or failed tasks can be retried",
        )

    def test_retry_does_not_exist(self):
        """Try retrying a nonexistent work request."""
        pk = self.work_request.pk
        self.work_request.delete()

        self.client.force_login(self.user)
        response = self.client.post(
            reverse(
                "work_requests:retry",
                kwargs={"pk": pk},
            )
        )
        self.assertContains(
            response,
            f"Work request {pk} not found",
            status_code=status.HTTP_404_NOT_FOUND,
        )

    def test_retry(self):
        """Try retrying a work request that cannot be retried."""
        self.client.force_login(self.user)
        response = self.client.post(
            reverse(
                "work_requests:retry",
                kwargs={"pk": self.work_request.pk},
            )
        )

        self.work_request.refresh_from_db()
        self.assertTrue(getattr(self.work_request, "superseded"))
        new_wr = self.work_request.superseded

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail",
                kwargs={"pk": new_wr.pk},
            ),
        )


class WorkRequestUnblockViewTests(ViewTestMixin, TestCase):
    """Tests for WorkRequestUnblockView."""

    user: ClassVar[User]
    workflow: ClassVar[WorkRequest]
    work_request: ClassVar[WorkRequest]

    @classmethod
    def setUpTestData(cls) -> None:
        """Initialize class data."""
        super().setUpTestData()
        cls.user = cls.playground.get_default_user()
        template = cls.playground.create_workflow_template("test", "noop")
        cls.workflow = WorkRequest.objects.create_workflow(
            template=template,
            data={},
            created_by=cls.user,
            status=WorkRequest.Statuses.RUNNING,
        )
        cls.work_request = WorkRequest.objects.create_synchronization_point(
            parent=cls.workflow,
            step="test",
            status=WorkRequest.Statuses.BLOCKED,
        )
        cls.work_request.unblock_strategy = WorkRequest.UnblockStrategy.MANUAL
        cls.work_request.save()

    def test_not_logged_in(self) -> None:
        """A non-authenticated request cannot review a blocked work request."""
        for method in ("get", "post"):
            with self.subTest(method):
                response = getattr(self.client, method)(
                    reverse(
                        "work_requests:unblock",
                        kwargs={"pk": self.work_request.pk},
                    )
                )

                self.assertContains(
                    response,
                    "You need to be authenticated to review a blocked Work "
                    "Request",
                    status_code=status.HTTP_403_FORBIDDEN,
                    html=True,
                )

    def test_without_permission(self) -> None:
        """A special permission is required."""
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            )
        )

        self.assertContains(
            response,
            "You need to be authenticated to review a blocked Work Request",
            status_code=status.HTTP_403_FORBIDDEN,
            html=True,
        )

    def test_does_not_exist(self) -> None:
        """Try unblocking a nonexistent work request."""
        pk = self.work_request.pk
        self.work_request.delete()
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse("work_requests:unblock", kwargs={"pk": pk}),
            {"action": "Accept"},
        )

        self.assertContains(
            response,
            f"Work request {pk} not found",
            status_code=status.HTTP_404_NOT_FOUND,
            html=True,
        )

    def test_invalid_form(self) -> None:
        """The view returns 400 if the form is invalid."""
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"notes": "\0"},
        )

        self.assertContains(
            response,
            str(ProhibitNullCharactersValidator.message),
            status_code=status.HTTP_400_BAD_REQUEST,
        )

    def test_invalid_action(self) -> None:
        """The view returns 400 if the action parameter is invalid."""
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"action": "Nonsense"},
        )

        self.assertContains(
            response,
            "Invalid action parameter: 'Nonsense'",
            status_code=status.HTTP_400_BAD_REQUEST,
            html=True,
        )

    def test_cannot_unblock(self) -> None:
        """The view returns 400 if the work request cannot be unblocked."""
        self.work_request.unblock_strategy = WorkRequest.UnblockStrategy.DEPS
        self.work_request.save()
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"action": "Accept"},
        )

        self.assertContains(
            response,
            f"Cannot unblock: Work request {self.work_request.pk} cannot be "
            f"manually unblocked",
            status_code=status.HTTP_400_BAD_REQUEST,
            html=True,
        )

    def test_accept(self) -> None:
        """Accept a blocked work request."""
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"action": "Accept"},
        )

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )
        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.PENDING)
        assert self.work_request.workflow_data is not None
        manual_unblock = self.work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 1)
        self.assertEqual(manual_unblock.log[0].user_id, self.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, timezone.now())
        self.assertIsNone(manual_unblock.log[0].notes)
        self.assertEqual(
            manual_unblock.log[0].action, WorkRequestManualUnblockAction.ACCEPT
        )

    def test_reject(self) -> None:
        """Reject a blocked work request."""
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"notes": "Go away", "action": "Reject"},
        )

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )
        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.ABORTED)
        assert self.work_request.workflow_data is not None
        manual_unblock = self.work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 1)
        self.assertEqual(manual_unblock.log[0].user_id, self.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, timezone.now())
        self.assertEqual(manual_unblock.log[0].notes, "Go away")
        self.assertEqual(
            manual_unblock.log[0].action, WorkRequestManualUnblockAction.REJECT
        )

    def test_record_notes_only(self) -> None:
        """Record notes on a blocked work request."""
        self.playground.add_user_permission(
            self.user, WorkRequest, "change_workrequest"
        )
        self.client.force_login(self.user)

        response = self.client.post(
            reverse(
                "work_requests:unblock", kwargs={"pk": self.work_request.pk}
            ),
            {"notes": "Not sure", "action": "Record notes only"},
        )

        self.assertRedirects(
            response,
            reverse(
                "work_requests:detail", kwargs={"pk": self.work_request.pk}
            ),
        )
        self.work_request.refresh_from_db()
        self.assertEqual(self.work_request.status, WorkRequest.Statuses.BLOCKED)
        assert self.work_request.workflow_data is not None
        manual_unblock = self.work_request.workflow_data.manual_unblock
        assert manual_unblock is not None
        self.assertEqual(len(manual_unblock.log), 1)
        self.assertEqual(manual_unblock.log[0].user_id, self.user.id)
        self.assertLess(manual_unblock.log[0].timestamp, timezone.now())
        self.assertEqual(manual_unblock.log[0].notes, "Not sure")
        self.assertIsNone(manual_unblock.log[0].action)
