Skip to content

Commit

Permalink
Merge pull request #1799 from uktrade/LTD-4648-add-document-streaming…
Browse files Browse the repository at this point in the history
…-endpoint

Add an endpoint to stream document data
  • Loading branch information
kevincarrogan authored Feb 5, 2024
2 parents e825a27 + c1a36ae commit bfa87fe
Show file tree
Hide file tree
Showing 8 changed files with 371 additions and 63 deletions.
2 changes: 1 addition & 1 deletion Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ ipdb = "*"
watchdog = {extras = ["watchmedo"], version = "*"}
diff-pdf-visually = "~=1.7.0"
pytest-circleci-parallelized = "~=0.1.0"
moto = {extras = ["s3"], version = "*"}

[packages]
factory-boy = "~=2.12.0"
Expand Down Expand Up @@ -72,7 +73,6 @@ django-silk = "~=5.0.3"
django = "~=4.2.8"
django-queryable-properties = "~=1.9.1"


[requires]
python_version = "3.8"
python_full_version = "3.8.18"
261 changes: 211 additions & 50 deletions Pipfile.lock

Large diffs are not rendered by default.

25 changes: 18 additions & 7 deletions api/documents/libraries/s3_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,24 @@
AWS_STORAGE_BUCKET_NAME,
)

_client = boto3.client(
"s3",
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
region_name=AWS_REGION,
config=Config(connect_timeout=S3_CONNECT_TIMEOUT, read_timeout=S3_REQUEST_TIMEOUT),
)

_client = None


def init_s3_client():
# We want to instantiate this once, ideally, but there may be cases where we
# want to explicitly re-instiate the client e.g. in tests.
global _client
_client = boto3.client(
"s3",
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
region_name=AWS_REGION,
config=Config(connect_timeout=S3_CONNECT_TIMEOUT, read_timeout=S3_REQUEST_TIMEOUT),
)


init_s3_client()


def get_object(document_id, s3_key):
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from django.urls import reverse

from api.documents import permissions
from test_helpers.clients import DataTestClient


class CertificateDownload(DataTestClient):
class DocumentDetail(DataTestClient):
def test_document_detail_as_caseworker(self):
# given there is a case document
case = self.create_standard_application_case(self.organisation)
Expand Down
118 changes: 118 additions & 0 deletions api/documents/tests/test_document_stream.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import boto3

from moto import mock_aws

from django.http import StreamingHttpResponse
from django.urls import reverse

from test_helpers.clients import DataTestClient

from api.conf.settings import (
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_REGION,
AWS_STORAGE_BUCKET_NAME,
)
from api.documents.libraries.s3_operations import init_s3_client


@mock_aws
class DocumentStream(DataTestClient):
def setUp(self):
super().setUp()
init_s3_client()
s3 = boto3.client(
"s3",
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
region_name=AWS_REGION,
)
s3.create_bucket(
Bucket=AWS_STORAGE_BUCKET_NAME,
CreateBucketConfiguration={
"LocationConstraint": AWS_REGION,
},
)
s3.put_object(
Bucket=AWS_STORAGE_BUCKET_NAME,
Key="thisisakey",
Body=b"test",
)

def test_document_stream_as_caseworker(self):
# given there is a case document
case = self.create_standard_application_case(self.organisation)
document = self.create_case_document(case=case, user=self.gov_user, name="jimmy")

# when a caseworker tries to access it
url = reverse("documents:document_stream", kwargs={"pk": document.pk})
response = self.client.get(url, **self.gov_headers)

# then they can
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, StreamingHttpResponse)
self.assertEqual(b"".join(response.streaming_content), b"test")

def test_document_stream_as_exporter(self):
# given there is a case document that is visible to the exporter
case = self.create_standard_application_case(self.organisation)
document = self.create_case_document(case=case, user=self.gov_user, name="jimmy")

# when the exporter tries to access it
url = reverse("documents:document_stream", kwargs={"pk": document.pk})
response = self.client.get(url, **self.exporter_headers)

# then they can
self.assertEqual(response.status_code, 200)
self.assertIsInstance(response, StreamingHttpResponse)
self.assertEqual(b"".join(response.streaming_content), b"test")

def test_document_stream_as_exporter_on_invisible_document(self):
# givem there is a document that's invisible to the exporter
case = self.create_standard_application_case(self.organisation)
document = self.create_case_document(case=case, user=self.gov_user, name="jimmy", visible_to_exporter=False)

# when the exporter tries to access it
url = reverse("documents:document_stream", kwargs={"pk": document.pk})
response = self.client.get(url, **self.exporter_headers)

# then they cannot
self.assertEqual(response.status_code, 403)

def test_document_stream_as_illegal_exporter(self):
# given there is a case document in organisation a
other_organisation, _ = self.create_organisation_with_exporter_user()
case = self.create_standard_application_case(other_organisation)
document = self.create_case_document(case=case, user=self.gov_user, name="jimmy", visible_to_exporter=False)

url = reverse("documents:document_stream", kwargs={"pk": document.pk})

# when user from organisation b tries to access it
response = self.client.get(url, **self.exporter_headers)

# then they are not able to
self.assertEqual(response.status_code, 403)

def test_document_stream_unsafe_file_as_caseworker(self):
# given there is a case document
case = self.create_standard_application_case(self.organisation)
document = self.create_case_document(case=case, user=self.gov_user, name="jimmy", safe=False)

# when a caseworker tries to access it
url = reverse("documents:document_stream", kwargs={"pk": document.pk})
response = self.client.get(url, **self.gov_headers)

# then they can
self.assertEqual(response.status_code, 404)

def test_document_stream_unsafe_file_as_exporter(self):
# given there is a case document that is visible to the exporter
case = self.create_standard_application_case(self.organisation)
document = self.create_case_document(case=case, user=self.gov_user, name="jimmy", safe=False)

# when the exporter tries to access it
url = reverse("documents:document_stream", kwargs={"pk": document.pk})
response = self.client.get(url, **self.exporter_headers)

# then they can
self.assertEqual(response.status_code, 404)
1 change: 1 addition & 0 deletions api/documents/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@
urlpatterns = [
path("<uuid:pk>/", views.DocumentDetail.as_view(), name="document"),
path("certificate/", views.DownloadSigningCertificate.as_view(), name="certificate"),
path("stream/<uuid:pk>/", views.DocumentStream.as_view(), name="document_stream"),
]
20 changes: 19 additions & 1 deletion api/documents/views.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
from rest_framework.views import APIView
from rest_framework.generics import RetrieveAPIView

from django.http import JsonResponse, HttpResponse
from django.http import (
HttpResponse,
JsonResponse,
)
from django.shortcuts import Http404

from api.cases.generated_documents.signing import get_certificate_data
from api.core.authentication import SharedAuthentication
from api.core.exceptions import NotFoundError
from api.documents.libraries.s3_operations import document_download_stream
from api.documents.models import Document
from api.documents.serializers import DocumentViewSerializer
from api.documents import permissions
Expand Down Expand Up @@ -41,3 +45,17 @@ def get(self, request):
response = HttpResponse(content=certificate, content_type="application/x-x509-ca-cert")
response["Content-Disposition"] = f'attachment; filename="LITECertificate.crt"'
return response


class DocumentStream(RetrieveAPIView):
"""
Get streamed content of a document.
"""

authentication_classes = (SharedAuthentication,)
queryset = Document.objects.filter(safe=True)
permission_classes = (permissions.IsCaseworkerOrInDocumentOrganisation,)

def retrieve(self, request, *args, **kwargs):
document = self.get_object()
return document_download_stream(document)
4 changes: 2 additions & 2 deletions test_helpers/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,7 @@ def submit_application(application: BaseApplication, user: ExporterUser = None):
return application

@staticmethod
def create_case_document(case: Case, user: GovUser, name: str, visible_to_exporter=True):
def create_case_document(case: Case, user: GovUser, name: str, visible_to_exporter=True, safe=True):
case_doc = CaseDocument(
case=case,
description="This is a document",
Expand All @@ -357,7 +357,7 @@ def create_case_document(case: Case, user: GovUser, name: str, visible_to_export
s3_key="thisisakey",
size=123456,
virus_scanned_at=None,
safe=None,
safe=safe,
visible_to_exporter=visible_to_exporter,
)
case_doc.save()
Expand Down

0 comments on commit bfa87fe

Please sign in to comment.