# Copyright 2021-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.

"""Views for the server application."""

import binascii
import copy
import itertools
import logging
import tempfile
from functools import lru_cache
from pathlib import Path
from typing import Any, Generic, TYPE_CHECKING, TypeVar

from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.db import transaction
from django.db.models import Model, QuerySet
from django.http import (
    Http404,
    HttpResponse,
    JsonResponse,
)
from django.utils import timezone

try:
    import pydantic.v1 as pydantic
except ImportError:
    import pydantic as pydantic  # type: ignore

from rest_framework import status, views
from rest_framework.exceptions import (
    NotFound,
    PermissionDenied as DRFPermissionDenied,
)
from rest_framework.generics import (
    CreateAPIView,
    DestroyAPIView,
    ListAPIView,
    RetrieveAPIView,
    UpdateAPIView,
)
from rest_framework.pagination import CursorPagination
from rest_framework.parsers import BaseParser, JSONParser
from rest_framework.permissions import BasePermission, DjangoModelPermissions
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.serializers import BaseSerializer

from debusine.db.models import (
    Artifact,
    ArtifactRelation,
    DEFAULT_WORKSPACE_NAME,
    File,
    FileInArtifact,
    FileInStore,
    FileStore,
    FileUpload,
    NotificationChannel,
    Token,
    User,
    WorkRequest,
    Worker,
    WorkflowTemplate,
    Workspace,
)
from debusine.server.collections.lookup import lookup_multiple, lookup_single
from debusine.server.serializers import (
    ArtifactRelationResponseSerializer,
    ArtifactSerializer,
    ArtifactSerializerResponse,
    CreateWorkflowRequestSerializer,
    LookupMultipleSerializer,
    LookupResponseSerializer,
    LookupSingleSerializer,
    WorkRequestCompletedSerializer,
    WorkRequestSerializer,
    WorkRequestUpdateSerializer,
    WorkerRegisterSerializer,
    WorkflowTemplateSerializer,
)
from debusine.tasks import BaseTask
from debusine.tasks.models import ActionTypes, LookupMultiple, TaskTypes
from debusine.utils import parse_content_range_header

logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    CreateAPIViewBase = CreateAPIView
    DestroyAPIViewBase = DestroyAPIView
    ListAPIViewBase = ListAPIView
    RetrieveAPIViewBase = RetrieveAPIView
    UpdateAPIViewBase = UpdateAPIView
else:
    # REST framework's generic views don't support generic types at run-time
    # yet.
    class _CreateAPIViewBase:
        def __class_getitem__(*args):
            return CreateAPIView

    class _DestroyAPIViewBase:
        def __class_getitem__(*args):
            return DestroyAPIView

    class _ListAPIViewBase:
        def __class_getitem__(*args):
            return ListAPIView

    class _RetrieveAPIViewBase:
        def __class_getitem__(*args):
            return RetrieveAPIView

    class _UpdateAPIViewBase:
        def __class_getitem__(*args):
            return UpdateAPIView

    CreateAPIViewBase = _CreateAPIViewBase
    DestroyAPIViewBase = _DestroyAPIViewBase
    ListAPIViewBase = _ListAPIViewBase
    RetrieveAPIViewBase = _RetrieveAPIViewBase
    UpdateAPIViewBase = _UpdateAPIViewBase


class ProblemResponse(JsonResponse):
    """
    Holds a title and other optional fields to return problems to the client.

    Follows RFC7807 (https://www.rfc-editor.org/rfc/rfc7807#section-6.1)
    """

    def __init__(
        self,
        title,
        detail=None,
        validation_errors=None,
        status_code=status.HTTP_400_BAD_REQUEST,
    ):
        """
        Initialize object.

        :param title: included in the response data.
        :param detail: if not None, included in the response data.
        :param validation_errors: if not None, included in the response data.
        :param status_code: HTTP status code for the response.
        """
        data = {"title": title}

        if detail is not None:
            data["detail"] = detail

        if validation_errors is not None:
            data["validation_errors"] = validation_errors

        super().__init__(
            data, status=status_code, content_type="application/problem+json"
        )


class IsTokenAuthenticated(BasePermission):
    """Allow access to requests with a valid token."""

    @lru_cache(1)
    def token(self, request) -> Token | None:
        """Return the token or None (if no token header or not found in DB)."""
        token_key = request.headers.get('token')

        if token_key is None:
            return None

        token = Token.objects.get_token_or_none(token_key=token_key)

        return token

    @staticmethod
    def _enabled_token(token: Token | None) -> bool:
        return token is not None and token.enabled

    def has_permission(self, request, view):  # noqa: U100
        """Return True if the request is authenticated with a Token."""
        token = self.token(request)

        return self._enabled_token(token)


class IsTokenUserAuthenticated(IsTokenAuthenticated):
    """Allow access if the request has an enabled Token with associated user."""

    def has_permission(self, request, view):  # noqa: U100
        """Return True if valid token has a User assigned."""
        token = self.token(request)

        return self._enabled_token(token) and token.user is not None


class IsTokenUserAuthenticatedDjangoModelPermissions(DjangoModelPermissions):
    """
    Allow access if a Token-authenticated user has model permissions.

    This is similar to :py:class:`DjangoModelPermissions`, except that it
    checks Token authentication.
    """

    def has_permission(self, request, view):
        """Return True if the user has the right permissions."""
        token = IsTokenUserAuthenticated().token(request)
        if not token or not token.user:
            return False

        queryset = self._queryset(view)
        perms = self.get_required_permissions(request.method, queryset.model)

        if not token.user.has_perms(perms):
            # Raise this rather than the default NotAuthenticated exception
            # when an authenticator fails; in this case the user is
            # authenticated, but doesn't have the right permissions.
            raise DRFPermissionDenied()

        return True


class IsUserAuthenticated(BasePermission):
    """Allow access to requests with an authenticated user."""

    def has_permission(self, request, view):  # noqa: U100
        """Return True if the request has an authenticated user."""
        return request.user.is_authenticated


class IsWorkerAuthenticated(IsTokenAuthenticated):
    """Allow access to requests with a token assigned to a worker."""

    def has_permission(self, request, view):
        """
        Return True if the request is an authenticated worker.

        The Token must exist in the database and have a Worker.
        """
        if super().has_permission(request, view) is False:
            # No token authenticated: no Worker Authenticated
            return False

        if not hasattr(self.token(request), 'worker'):
            # The Token doesn't have a worker associated
            return False

        return True


class IsGet(BasePermission):
    """Allow access if the request's method is GET."""

    def has_permission(self, request, view):  # noqa: U100
        """Return True if request.method == "GET"."""
        return request.method == "GET"


class RegisterView(views.APIView):
    """View used by workers to register to debusine."""

    def post(self, request):
        """Worker registers (sends token and fqdn)."""
        worker_register = WorkerRegisterSerializer(data=request.data)

        if not worker_register.is_valid():
            return ProblemResponse(
                "Cannot deserialize worker",
                validation_errors=worker_register.errors,
            )

        token_key = worker_register.validated_data['token']
        fqdn = worker_register.validated_data['fqdn']

        token_hash = Token._generate_hash(token_key)
        token, _ = Token.objects.get_or_create(hash=token_hash)

        Worker.objects.create_with_fqdn(fqdn, token)

        logger.info('Client registered. Token key: %s', token_key)

        return Response(status=status.HTTP_201_CREATED)


class GetNextWorkRequestView(views.APIView):
    """View used by workers to request a task."""

    permission_classes = [IsWorkerAuthenticated]

    def get(self, request):
        """Return the task to build."""
        token_key = request.headers['token']
        worker = Worker.objects.get_worker_by_token_key_or_none(token_key)

        work_request = WorkRequest.objects.running(worker=worker).first()

        if work_request is None:
            work_request = WorkRequest.objects.pending(worker=worker).first()

        if work_request:
            content = WorkRequestSerializer(work_request).data
            work_request.mark_running()
            status_code = status.HTTP_200_OK
        else:
            # There is no work request available for the worker
            content = None
            status_code = status.HTTP_204_NO_CONTENT

        return Response(content, status=status_code)


class WorkRequestPagination(CursorPagination):
    """Pagination for lists of work requests."""

    ordering = "-created_at"


class WorkRequestView(
    ListAPIViewBase[WorkRequest], UpdateAPIViewBase[WorkRequest]
):
    """View used by the debusine client to get information of a WorkRequest."""

    permission_classes = [
        IsTokenUserAuthenticated | IsTokenAuthenticated & IsGet
    ]
    serializer_class = WorkRequestSerializer
    pagination_class = WorkRequestPagination
    queryset = WorkRequest.objects.all()
    lookup_url_kwarg = "work_request_id"

    def get(self, request, work_request_id: int | None = None):
        """Return status information for WorkRequest or not found."""
        if work_request_id is None:
            return super().get(request)

        try:
            work_request = WorkRequest.objects.get(pk=work_request_id)
        except WorkRequest.DoesNotExist:
            return Response(
                {'detail': 'Work request id not found'},
                status=status.HTTP_404_NOT_FOUND,
            )

        return Response(
            WorkRequestSerializer(work_request).data, status=status.HTTP_200_OK
        )

    def post(self, request):
        """Create a new work request."""
        data = request.data.copy()

        # "workspace" might not be in data, but it is a mandatory field
        # in the serializer. If not sent by the client use the default workspace
        if "workspace" not in data:
            data["workspace"] = DEFAULT_WORKSPACE_NAME

        # only accept send-notification actions
        try:
            data["event_reactions"] = {
                event: [
                    action
                    for action in reactions
                    if action["action"] == ActionTypes.SEND_NOTIFICATION
                ]
                for event, reactions in data.get("event_reactions", {}).items()
            }
        except (TypeError, KeyError, ValueError, AttributeError):
            return ProblemResponse("Invalid event_reactions")

        token = Token.objects.get(
            hash=Token._generate_hash(request.headers["token"])
        )

        data["created_by"] = token.user.id
        work_request_deserialized = WorkRequestSerializer(
            data=data,
            only_fields=[
                'task_name',
                'task_data',
                'event_reactions',
                'workspace',
                'created_by',
            ],
        )

        if not work_request_deserialized.is_valid():
            errors = work_request_deserialized.errors

            logger.debug(
                "Error creating work request. Could not be deserialized: %s",
                errors,
            )

            return ProblemResponse(
                "Cannot deserialize work request", validation_errors=errors
            )

        if len(non_existing_channels := self._non_existing_channels(data)) > 0:
            return ProblemResponse(
                f"Non-existing channels: {sorted(non_existing_channels)}"
            )

        task_name = data['task_name']

        task_types = [TaskTypes.WORKER]
        if token.user.is_superuser:
            task_types.append(TaskTypes.SERVER)

        for task_type in task_types:
            if BaseTask.is_valid_task_name(task_type, task_name):
                break
        else:
            valid_task_names = ', '.join(
                itertools.chain(
                    *(
                        BaseTask.task_names(task_type)
                        for task_type in task_types
                    ),
                ),
            )
            logger.debug(
                "Error creating work request: task name is not registered: %s",
                task_name,
            )
            return ProblemResponse(
                "Cannot create work request: task name is not registered",
                detail=f'Task name: "{task_name}". '
                f"Registered task names: {valid_task_names}",
            )

        w = work_request_deserialized.save(task_type=task_type)

        w.refresh_from_db()

        # Force EventReactions parsing
        try:
            w.event_reactions
        except (TypeError, pydantic.ValidationError):
            return ProblemResponse("Invalid event_reactions")

        return Response(
            WorkRequestSerializer(w).data, status=status.HTTP_200_OK
        )

    @staticmethod
    def _non_existing_channels(data: dict[str, Any]) -> set[str]:
        """Return non existing channels from event_reactions."""
        non_existing_channels: set[str] = set()

        for event, reactions in data.get("event_reactions", {}).items():
            for action in reactions:
                assert (
                    action["action"] == ActionTypes.SEND_NOTIFICATION
                )  # filtered above
                channel_name = action["channel"]
                try:
                    NotificationChannel.objects.get(name=channel_name)
                except NotificationChannel.DoesNotExist:
                    non_existing_channels.add(channel_name)

        return non_existing_channels

    # TODO: We can't declare the return type properly here because the
    # return type of UpdateAPIView.patch is rest_framework.response.Response
    # and ProblemResponse isn't a subtype of that.  It works anyway, but
    # needs to be fixed somehow.
    def patch(self, request: Request, *args: Any, **kwargs: Any):
        """Update properties of a work request."""
        # Only a subset of properties may be patched.
        work_request_deserialized = WorkRequestUpdateSerializer(
            data=request.data
        )
        work_request_deserialized.is_valid(raise_exception=True)

        # Only users with appropriate permissions may set priority
        # adjustments.
        priority_adjustment = work_request_deserialized.validated_data.get(
            "priority_adjustment"
        )
        if priority_adjustment is None:
            pass
        else:
            token = IsTokenUserAuthenticated().token(request)
            # permission_classes is declared such that we won't get this far
            # unless the request has an enabled token with an associated
            # user.
            assert token is not None
            assert token.user is not None
            # Users with manage_workrequest_priorities have full access.
            if token.user.has_perm("db.manage_workrequest_priorities"):
                pass
            # Users can set non-positive priority adjustments on their own
            # work requests.
            elif (
                priority_adjustment <= 0
                and token.user == self.get_object().created_by
            ):
                pass
            else:
                return ProblemResponse(
                    "You are not permitted to set priority adjustments",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

        return super().patch(request, *args, **kwargs)


class UpdateWorkRequestAsCompletedView(views.APIView):
    """View used by the workers to mark a task as completed."""

    permission_classes = [IsWorkerAuthenticated]

    def put(self, request, work_request_id: int):
        """Mark a work request as completed."""
        token_key = request.headers['token']
        worker = Worker.objects.get_worker_by_token_key_or_none(token_key)

        try:
            work_request = WorkRequest.objects.get(pk=work_request_id)
        except WorkRequest.DoesNotExist:
            return ProblemResponse(
                "Work request not found",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        if work_request.worker == worker:
            work_request_completed_serializer = WorkRequestCompletedSerializer(
                data=request.data
            )

            if work_request_completed_serializer.is_valid():
                work_request.mark_completed(
                    work_request_completed_serializer.validated_data['result']
                )
                content = None
                status_code = status.HTTP_200_OK
            else:
                return ProblemResponse(
                    "Cannot change work request as completed",
                    validation_errors=work_request_completed_serializer.errors,
                )
        else:
            return ProblemResponse(
                "Invalid worker to update the work request",
                status_code=status.HTTP_401_UNAUTHORIZED,
            )

        return Response(content, status=status_code)


class UpdateWorkerDynamicMetadataView(views.APIView):
    """View used by the workers to post dynamic metadata."""

    permission_classes = [IsWorkerAuthenticated]

    def put(self, request):
        """Update Worker dynamic metadata."""
        token_key = request.headers['token']
        worker = Worker.objects.get_worker_by_token_key_or_none(token_key)

        worker.set_dynamic_metadata(request.data)

        return Response(status=status.HTTP_204_NO_CONTENT)


class ArtifactView(views.APIView):
    """View used to get or create artifacts."""

    permission_classes = [IsTokenAuthenticated]

    def get(self, request, artifact_id: int):
        """Return information of artifact_id."""
        try:
            artifact = Artifact.objects.get(id=artifact_id)
        except Artifact.DoesNotExist:
            return ProblemResponse(
                f"Artifact {artifact_id} not found",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        artifact_response = ArtifactSerializerResponse.from_artifact(
            artifact, request
        )

        return Response(artifact_response.data, status=status.HTTP_200_OK)

    @transaction.atomic
    def post(self, request: Request) -> HttpResponse:
        """Create a new artifact."""
        data = request.data.copy()

        # "workspace" might not be in data, but it is a mandatory field
        # in the serializer.
        # This view might change in the future and use a different "workspace"
        # depending on the user/token that is creating the Artifact.
        # Currently, it uses the DEFAULT_WORKSPACE_NAME
        if "workspace" not in data:
            data["workspace"] = DEFAULT_WORKSPACE_NAME

        artifact_deserialized = ArtifactSerializer(data=data)
        if not artifact_deserialized.is_valid():
            return ProblemResponse(
                "Cannot deserialize artifact",
                validation_errors=artifact_deserialized.errors,
            )

        workspace_name = artifact_deserialized["workspace"].value
        try:
            workspace = Workspace.objects.get(name=workspace_name)
        except Workspace.DoesNotExist:
            return ProblemResponse(
                f'Workspace "{workspace_name}" cannot be found'
            )

        work_request_id: int | None = artifact_deserialized.validated_data.get(
            "work_request"
        )
        created_by: User | None = None

        if work_request_id is not None:
            if not IsWorkerAuthenticated().has_permission(request, self):
                return ProblemResponse(
                    "work_request field can only be used by a request with a "
                    "token from a Worker",
                    status_code=status.HTTP_403_FORBIDDEN,
                )

            try:
                WorkRequest.objects.get(id=work_request_id)
            except WorkRequest.DoesNotExist:
                return ProblemResponse(
                    f"WorkRequest {work_request_id} does not exist"
                )
        else:
            if not IsTokenUserAuthenticated().has_permission(request, self):
                return ProblemResponse(
                    "If work_request field is empty the WorkRequest must be "
                    "created using a Token associated to a user",
                    status_code=status.HTTP_403_FORBIDDEN,
                )
            token_key = request.headers.get("token")
            # The user is authenticated, so there must be a token
            assert token_key is not None
            created_by = Token.objects.get(
                hash=Token._generate_hash(token_key)
            ).user

        artifact = Artifact.objects.create(
            category=artifact_deserialized["category"].value,
            workspace=workspace,
            data=artifact_deserialized["data"].value,
            created_by_work_request_id=work_request_id,
            expiration_delay=artifact_deserialized["expiration_delay"].value,
            created_by=created_by,
        )

        for path, file_deserialized in artifact_deserialized[
            "files"
        ].value.items():
            file_size = file_deserialized["size"]
            hash_digest = binascii.unhexlify(
                file_deserialized["checksums"]["sha256"]
            )

            fileobj, created = File.get_or_create(
                hash_digest=hash_digest,
                size=file_size,
            )

            file_in_artifact = FileInArtifact.objects.create(
                artifact=artifact, file=fileobj, path=path
            )

            if (
                created or not artifact.workspace.is_file_in_workspace(fileobj)
            ) and file_size == 0:
                # Create the file upload and add the file in the store
                temp_empty_file = tempfile.NamedTemporaryFile(
                    prefix="file-uploading-empty-",
                    dir=settings.DEBUSINE_UPLOAD_DIRECTORY,
                    delete=False,
                )
                temp_empty_file.close()
                file_upload = FileUpload.objects.create(
                    file_in_artifact=file_in_artifact,
                    path=Path(temp_empty_file.name).name,
                )
                UploadFileView.create_file_in_storage(file_upload)

        artifact_response = ArtifactSerializerResponse.from_artifact(
            artifact, request
        )
        return Response(artifact_response.data, status=status.HTTP_201_CREATED)


class FileUploadParser(BaseParser):
    """View used to upload files into an artifact."""

    media_type = "*/*"

    @staticmethod
    def _raise_error_if_gap_in_file(content_range, temporary_file_path):
        if content_range is None:
            return

        uploaded_size = Path(temporary_file_path).stat().st_size
        if uploaded_size < content_range["start"]:
            raise ValueError(
                f"Server had received {uploaded_size} bytes of the file. "
                f"New upload starts at {content_range['start']}. "
                f"Please continue from {uploaded_size}"
            )

    def parse(self, stream, media_type=None, parser_context=None):  # noqa: U100
        """Upload a file to an artifact."""
        file_in_artifact = parser_context["request"].file_in_artifact

        range_start = 0
        range_end = None

        content_range = parse_content_range_header(
            parser_context["request"].headers
        )

        if content_range is not None:
            range_start = content_range["start"]
            range_end = content_range["end"]

        if range_start > (file_size := file_in_artifact.file.size):
            raise ValueError(
                f"Range start ({range_start}) is greater than "
                f"file size ({file_size})"
            )

        try:
            file_upload = FileUpload.objects.get(
                file_in_artifact=file_in_artifact
            )
            temporary_file_path = file_upload.absolute_file_path()
            self._raise_error_if_gap_in_file(content_range, temporary_file_path)
            uploads_file = open(temporary_file_path, "r+b")
        except FileUpload.DoesNotExist:
            uploads_file = tempfile.NamedTemporaryFile(
                prefix='file-uploading-',
                dir=settings.DEBUSINE_UPLOAD_DIRECTORY,
                delete=False,
                mode="a+b",
            )
            file_upload = FileUpload.objects.create(
                file_in_artifact=file_in_artifact,
                path=Path(uploads_file.name).name,
            )

        uploads_file.seek(range_start)

        bytes_received = 0
        # DATA_UPLOAD_MAX_MEMORY_SIZE: arbitrary size that should make sense
        # that can be dealt with
        while chunk := stream.read(settings.DATA_UPLOAD_MAX_MEMORY_SIZE):
            bytes_received += len(chunk)

            if range_start + bytes_received > file_size:
                raise ValueError(
                    f"Cannot process range: attempted to write after end "
                    f"of the file. Range start: {range_start} "
                    f"Received: {bytes_received} File size: {file_size}"
                )

            uploads_file.write(chunk)

        if (
            range_end is not None
            and (expected_bytes := range_end - range_start + 1)
            != bytes_received
        ):
            raise ValueError(
                f"Expected {expected_bytes} bytes (based on range header: "
                f"end-start+1). Received: {bytes_received} bytes"
            )

        uploads_file.close()

        file_upload.last_activity_at = timezone.now()
        file_upload.save()

        return file_upload


class UploadFileView(views.APIView):
    """View to upload files."""

    parser_classes = [FileUploadParser]
    permission_classes = [IsTokenAuthenticated]

    @staticmethod
    def _range_header(start, end):
        return {"Range": f"bytes={start}-{end}"}

    @classmethod
    def _file_status_response(cls, file_in_artifact, range_file_size):
        """
        Return response with the status of a file (completed or missing range).

        :param file_in_artifact: FileInArtifact that is being uploaded
        :param range_file_size: if it's "*" returns the total file size,
          if it contains a number and does not match the expected
          the server returns HTTP 400 Bad request
        """
        file_upload = getattr(file_in_artifact, "fileupload", None)

        if file_upload is None:
            return Response(
                status=status.HTTP_206_PARTIAL_CONTENT,
                headers=cls._range_header(0, 0),
            )

        file_in_db_size = file_upload.file_in_artifact.file.size
        if range_file_size != "*" and range_file_size != file_in_db_size:
            # Client reported a size in Content-Range that is not
            # what was expected
            return ProblemResponse(
                "Invalid file size in Content-Range header. "
                f"Expected {file_in_db_size} "
                f"received {range_file_size}"
            )

        current_size = FileUpload.current_size(
            artifact=file_in_artifact.artifact,
            path_in_artifact=file_in_artifact.path,
        )

        if current_size == file_in_db_size:
            # No missing parts: file upload is already completed
            return Response(status=status.HTTP_200_OK)
        else:
            # Return HTTP 206 with the Range: 0-{uploaded_size}
            # so the client can continue the upload
            return Response(
                status=status.HTTP_206_PARTIAL_CONTENT,
                headers=cls._range_header(0, current_size),
            )

    @transaction.atomic
    def put(self, request, artifact_id: int, file_path: str):
        """Receive file to be uploaded or request uploaded content-range."""
        try:
            file_in_artifact = _get_file_in_artifact_or_raise_value_error(
                artifact_id,
                file_path,
            )
        except ValueError as exc:
            return ProblemResponse(
                str(exc), status_code=status.HTTP_404_NOT_FOUND
            )

        try:
            content_range = parse_content_range_header(request.headers)
        except ValueError as exc:
            return ProblemResponse(str(exc))

        artifact = file_in_artifact.artifact

        if content_range is not None and content_range["start"] == "*":
            # Client would like to know the status of a file
            # (which parts have been uploaded)
            return self._file_status_response(
                file_in_artifact, content_range["size"]
            )

        if self._file_already_uploaded(file_in_artifact.file):
            return Response(status=status.HTTP_201_CREATED)

        request.file_in_artifact = file_in_artifact
        # request.data use the FileUploadParser
        try:
            file_upload: FileUpload = request.data
        except ValueError as value_error:
            return ProblemResponse(str(value_error))

        current_size = FileUpload.current_size(
            artifact=artifact, path_in_artifact=file_path
        )

        if current_size != file_upload.file_in_artifact.file.size:
            return Response(
                status=status.HTTP_200_OK,
                headers=self._range_header(0, current_size),
            )
        else:
            # File is uploaded: create the file in the storage
            # Will return HTTP 201 Created (or HTTP 409 Conflict if the
            # expected hash does not match, the client will need to re-upload)
            response = self.create_file_in_storage(file_upload)
            file_upload.delete()
            return response

    @staticmethod
    def create_file_in_storage(file_upload: FileUpload):
        """
        Add file_upload to the artifact's default store.

        Return Response: 201 Created if real hash and length with the
        expected hash and length match, 409 Conflict if no match
        (and deletes the FileUpload): the client need to upload the file again.
        """
        file_path = file_upload.absolute_file_path()

        hash_digest = File.calculate_hash(file_path)

        file_in_artifact = file_upload.file_in_artifact

        expected_file_hash_digest = file_in_artifact.file.hash_digest
        if file_in_artifact.file.hash_digest.hex() != hash_digest.hex():
            return ProblemResponse(
                "Invalid file hash. Expected: "
                f"{expected_file_hash_digest.hex()} "
                f"Received: {hash_digest.hex()}",
                status_code=status.HTTP_409_CONFLICT,
            )

        workspace = file_in_artifact.artifact.workspace
        file_backend = workspace.default_file_store.get_backend_object()

        file_backend.add_file(file_path, fileobj=file_in_artifact.file)

        return Response(status=status.HTTP_201_CREATED)

    @staticmethod
    def _file_already_uploaded(file):
        return FileInStore.objects.filter(
            store=FileStore.default(),
            file=file,
        ).exists()


def _get_file_in_artifact_or_raise_value_error(
    artifact_id: int, file_path: str
) -> FileInArtifact:
    try:
        artifact = Artifact.objects.get(id=artifact_id)
    except Artifact.DoesNotExist:
        raise ValueError(
            f"Artifact {artifact_id} does not exist",
        )

    try:
        file_in_artifact = FileInArtifact.objects.get(
            artifact=artifact, path=file_path
        )
    except FileInArtifact.DoesNotExist:
        raise ValueError(
            f'No file_path "{file_path}" for artifact {artifact.id}'
        )

    return file_in_artifact


class ValidatePermissionsMixin:
    """
    Mixin implementing check_permissions() for the Views.

    To use it:
        class YourView(ValidatePermissionsMixin, View):
            permission_denied_message = "Message explaining the problem"
            permission_classes = [IsUserAuthenticated | IsTokenAuthenticated]

    It overrides dispatch() and raise PermissionDenied if the request cannot
    proceed.
    """

    def check_permissions(self):
        """
        Raise PermissionDenied() if one permission does not allow access.

        Same approach as Django REST ApiView methods: if no permissions:
        it is allowed. If any class denies access raises PermissionDenied().

        See rest_framework/views.py ApiView.check_permissions.
        """
        for permission_class in self.permission_classes:
            permission = permission_class()
            if not permission.has_permission(self.request, None):
                message = getattr(self, "permission_denied_message", None)
                raise PermissionDenied(message)

    def dispatch(self, request, *args, **kwargs):
        """Check permissions for any request method (GET, POST, PUT, etc.)."""
        self.check_permissions()

        return super().dispatch(request, *args, **kwargs)


class ArtifactInPublicWorkspace(BasePermission):
    """Allows access for Artifacts with public workspace."""

    def has_permission(self, request, view):  # noqa: U100
        """
        Return True if request's artifact's workspace is public.

        :raise Http404: if the artifact_id does not exist.
        """
        artifact_id = request.resolver_match.kwargs.get("artifact_id")

        try:
            artifact = Artifact.objects.get(id=artifact_id)
        except Artifact.DoesNotExist:
            raise Http404(f"Artifact {artifact_id} does not exist")

        return artifact.workspace.public


T = TypeVar("T", bound=Model, covariant=True)


class GetOrCreateAPIView(CreateAPIViewBase[T], Generic[T]):
    """Class to create a new object (return 201 created) or return 200."""

    def create(self, request, *args, **kwargs):
        """
        Return the object with HTTP 201 (created) or HTTP 200 (already existed).

        It is a modification of CreateAPIView.create().

        The default behaviour in rest_framework when an object cannot be
        inserted because it already exists is to return HTTP 400. In
        GetOrCreateApiView it returns HTTP 200 and the client knows that
        the object that wanted to create was already in the database.
        """
        serializer = self.get_serializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        try:
            obj = serializer.Meta.model.objects.get(**serializer.validated_data)
            response_status = status.HTTP_200_OK
            pk = obj.pk
        except serializer.Meta.model.DoesNotExist:
            self.perform_create(serializer)
            response_status = status.HTTP_201_CREATED
            pk = serializer.instance.pk

        headers = self.get_success_headers(serializer.data)

        response_data = copy.deepcopy(serializer.data)
        response_data["id"] = pk

        return Response(response_data, status=response_status, headers=headers)


class ArtifactRelationsView(
    GetOrCreateAPIView[ArtifactRelation],
    DestroyAPIViewBase[ArtifactRelation],
    ListAPIViewBase[ArtifactRelation],
):
    """Return, create and delete relations between artifacts."""

    serializer_class = ArtifactRelationResponseSerializer
    parser_classes = [JSONParser]
    pagination_class = None
    queryset = ArtifactRelation.objects

    def get(self, request, *args, **kwargs):
        """Return list of ArtifactRelations for artifact or target_artifact."""
        artifact_id = request.GET.get("artifact", None)
        target_artifact_id = request.GET.get("target_artifact", None)

        if artifact_id is not None:
            filter_kwargs = {"artifact_id": artifact_id}
            required_artifact_id = artifact_id
        elif target_artifact_id is not None:
            filter_kwargs = {"target_id": target_artifact_id}
            required_artifact_id = target_artifact_id
        else:
            return ProblemResponse(
                '"artifact" or "target_artifact" parameter are mandatory'
            )
        try:
            Artifact.objects.get(id=required_artifact_id)
        except Artifact.DoesNotExist:
            return ProblemResponse(
                f"Artifact {required_artifact_id} not found",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        self.queryset = ArtifactRelation.objects.filter(**filter_kwargs)

        return super().get(request, *args, **kwargs)


class LookupSingleView(views.APIView):
    """View used to look up a single collection item."""

    permission_classes = [IsWorkerAuthenticated]
    parser_classes = [JSONParser]

    def post(self, request):
        """
        Resolve a lookup for a single collection item.

        The request data must be a :ref:`lookup-single`.
        """
        token_key = request.headers["token"]
        worker = Worker.objects.get_worker_by_token_key_or_none(token_key)

        lookup_deserialized = LookupSingleSerializer(data=request.data)
        lookup_deserialized.is_valid(raise_exception=True)
        work_request = lookup_deserialized.validated_data["work_request"]
        lookup_user = request.user if request.user.is_authenticated else None

        if work_request.worker != worker:
            return ProblemResponse(
                f"Work request {work_request.id} is not assigned to the "
                f"authenticated worker",
                status_code=status.HTTP_401_UNAUTHORIZED,
            )

        try:
            result = lookup_single(
                lookup_deserialized.validated_data["lookup"],
                work_request.workspace,
                user=lookup_user,
                default_category=lookup_deserialized.validated_data.get(
                    "default_category"
                ),
                workflow_root=work_request.get_workflow_root(),
                expect_type=lookup_deserialized.validated_data["expect_type"],
            )
        except KeyError as e:
            return ProblemResponse(
                "No matches",
                detail=str(e),
                status_code=status.HTTP_404_NOT_FOUND,
            )
        except LookupError as e:
            return ProblemResponse(
                "Lookup error",
                detail=str(e),
                status_code=status.HTTP_400_BAD_REQUEST,
            )

        return Response(
            LookupResponseSerializer(result).data, status=status.HTTP_200_OK
        )


class LookupMultipleView(views.APIView):
    """View used to look up multiple collection items."""

    permission_classes = [IsWorkerAuthenticated]
    parser_classes = [JSONParser]

    def post(self, request):
        """
        Resolve a lookup for a single collection item.

        The request data must be a :ref:`lookup-multiple`.
        """
        token_key = request.headers["token"]
        worker = Worker.objects.get_worker_by_token_key_or_none(token_key)

        lookup_deserialized = LookupMultipleSerializer(data=request.data)
        lookup_deserialized.is_valid(raise_exception=True)
        work_request = lookup_deserialized.validated_data["work_request"]
        lookup_user = request.user if request.user.is_authenticated else None

        if work_request.worker != worker:
            return ProblemResponse(
                f"Work request {work_request.id} is not assigned to the "
                f"authenticated worker",
                status_code=status.HTTP_401_UNAUTHORIZED,
            )

        try:
            lookup = LookupMultiple.parse_obj(
                lookup_deserialized.validated_data["lookup"]
            )
        except ValueError as e:
            return ProblemResponse(
                "Cannot deserialize lookup",
                validation_errors={"lookup": str(e)},
            )

        try:
            result = lookup_multiple(
                lookup,
                work_request.workspace,
                user=lookup_user,
                default_category=lookup_deserialized.validated_data.get(
                    "default_category"
                ),
                workflow_root=work_request.get_workflow_root(),
                expect_type=lookup_deserialized.validated_data["expect_type"],
            )
        except LookupError as e:
            return ProblemResponse(
                "Lookup error",
                detail=str(e),
                status_code=status.HTTP_400_BAD_REQUEST,
            )

        return Response(
            LookupResponseSerializer(result, many=True).data,
            status=status.HTTP_200_OK,
        )


class WorkflowTemplateView(
    CreateAPIViewBase[WorkflowTemplate],
    RetrieveAPIViewBase[WorkflowTemplate],
    UpdateAPIViewBase[WorkflowTemplate],
    DestroyAPIViewBase[WorkflowTemplate],
):
    """Return, create and delete workflow templates."""

    permission_classes = [IsTokenUserAuthenticatedDjangoModelPermissions]
    serializer_class = WorkflowTemplateSerializer
    parser_classes = [JSONParser]
    pagination_class = None

    def get_queryset(self) -> QuerySet[WorkflowTemplate]:
        """Get the query set for this view."""
        # TODO: We should check permissions on the workspace somewhere, but
        # there's no infrastructure for that yet:
        # https://salsa.debian.org/freexian-team/debusine/-/issues/80
        if self.request.method == "GET":
            workspace_name = self.request.GET.get(
                "workspace", DEFAULT_WORKSPACE_NAME
            )
        else:
            workspace_name = self.request.data.get(
                "workspace", DEFAULT_WORKSPACE_NAME
            )

        return WorkflowTemplate.objects.filter(workspace__name=workspace_name)

    def get_object(self) -> WorkflowTemplate:
        """Override to return more API-friendly errors."""
        try:
            return super().get_object()
        except Http404 as exc:
            raise NotFound(str(exc))

    def _check_permissions(self, serializer: BaseSerializer[WorkflowTemplate]):
        """Only users with appropriate permissions may set priorities."""
        priority = serializer.validated_data.get("priority")
        if priority is not None and priority > 0:
            token = IsTokenUserAuthenticated().token(self.request)
            # permission_classes is declared such that we won't get this far
            # unless the request has an enabled token with an associated
            # user.
            assert token is not None
            assert token.user is not None
            if not token.user.has_perm("db.manage_workrequest_priorities"):
                raise DRFPermissionDenied(
                    "You are not permitted to set positive priorities"
                )

    def perform_create(self, serializer: BaseSerializer[WorkflowTemplate]):
        """Create a workflow template."""
        self._check_permissions(serializer)
        super().perform_create(serializer)

    def perform_update(self, serializer: BaseSerializer[WorkflowTemplate]):
        """Update a workflow template."""
        self._check_permissions(serializer)
        super().perform_update(serializer)


class WorkflowView(views.APIView):
    """Create workflows from a template."""

    permission_classes = [IsTokenUserAuthenticated]

    def post(self, request: Request):
        """Create a new workflow from a template."""
        serializer = CreateWorkflowRequestSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)

        try:
            template = WorkflowTemplate.objects.get(
                name=serializer.validated_data["template_name"],
                workspace=serializer.validated_data["workspace"],
            )
        except WorkflowTemplate.DoesNotExist:
            return ProblemResponse(
                "Workflow template not found",
                status_code=status.HTTP_404_NOT_FOUND,
            )

        token = IsTokenUserAuthenticated().token(request)
        # permission_classes is declared such that we won't get this far
        # unless the request has an enabled token with an associated user.
        assert token is not None
        assert token.user is not None

        workflow = WorkRequest.objects.create_workflow(
            template=template,
            data=serializer.validated_data["task_data"],
            created_by=token.user,
        )
        return Response(
            WorkRequestSerializer(workflow).data, status=status.HTTP_201_CREATED
        )
