Skip to content

Commit

Permalink
Merge pull request #73 from ral-facilities/add-get-endpoint-for-attac…
Browse files Browse the repository at this point in the history
…hment-metadata-#25

Add get endpoint for a list of attachment metadata #25
  • Loading branch information
rowan04 authored Dec 12, 2024
2 parents 0477879 + f645bb8 commit 1620e19
Show file tree
Hide file tree
Showing 11 changed files with 344 additions and 17 deletions.
28 changes: 28 additions & 0 deletions object_storage_api/repositories/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,31 @@ def get(self, attachment_id: str, session: ClientSession = None) -> Optional[Att
if attachment:
return AttachmentOut(**attachment)
return None

def list(self, entity_id: Optional[str], session: ClientSession = None) -> list[AttachmentOut]:
"""
Retrieve attachments from a MongoDB database.
:param session: PyMongo ClientSession to use for database operations.
:param entity_id: The ID of the entity to filter attachments by.
:return: List of attachments or an empty list if no attachments are retrieved.
"""

# There is some duplicate code here, due to the attachments and images methods being very similar
# pylint: disable=duplicate-code

query = {}
if entity_id is not None:
query["entity_id"] = CustomObjectId(entity_id)

message = "Retrieving all attachments from the database"
if not query:
logger.info(message)
else:
logger.info("%s matching the provided filter(s)", message)
logger.debug("Provided filter(s): %s", query)

# pylint: enable=duplicate-code

attachments = self._attachments_collection.find(query, session=session)
return [AttachmentOut(**attachment) for attachment in attachments]
5 changes: 5 additions & 0 deletions object_storage_api/repositories/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ def list(self, entity_id: Optional[str], primary: Optional[bool], session: Clien
:return: List of images or an empty list if no images are retrieved.
"""

# There is some duplicate code here, due to the attachments and images methods being very similar
# pylint: disable=duplicate-code

query = {}
if entity_id is not None:
query["entity_id"] = CustomObjectId(entity_id)
Expand All @@ -88,5 +91,7 @@ def list(self, entity_id: Optional[str], primary: Optional[bool], session: Clien
logger.info("%s matching the provided filter(s)", message)
logger.debug("Provided filter(s): %s", query)

# pylint: enable=duplicate-code

images = self._images_collection.find(query, session=session)
return [ImageOut(**image) for image in images]
28 changes: 25 additions & 3 deletions object_storage_api/routers/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@
"""

import logging
from typing import Annotated
from typing import Annotated, Optional

from fastapi import APIRouter, Depends, status
from fastapi import APIRouter, Depends, Query, status

from object_storage_api.schemas.attachment import AttachmentPostResponseSchema, AttachmentPostSchema
from object_storage_api.schemas.attachment import (
AttachmentMetadataSchema,
AttachmentPostResponseSchema,
AttachmentPostSchema,
)
from object_storage_api.services.attachment import AttachmentService

logger = logging.getLogger()
Expand All @@ -33,3 +37,21 @@ def create_attachment(
logger.debug("Attachment data: %s", attachment)

return attachment_service.create(attachment)


@router.get(
path="",
summary="Get attachments",
response_description="List of attachments",
)
def get_attachments(
attachment_service: AttachmentServiceDep,
entity_id: Annotated[Optional[str], Query(description="Filter attachments by entity ID")] = None,
) -> list[AttachmentMetadataSchema]:
# pylint: disable=missing-function-docstring
logger.info("Getting attachments")

if entity_id is not None:
logger.debug("Entity ID filter: '%s'", entity_id)

return attachment_service.list(entity_id)
11 changes: 9 additions & 2 deletions object_storage_api/schemas/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,19 @@ class AttachmentPostUploadInfoSchema(BaseModel):
fields: dict = Field(description="Form fields required for submitting the attachment file upload request")


class AttachmentPostResponseSchema(CreatedModifiedSchemaMixin, AttachmentPostSchema):
class AttachmentMetadataSchema(AttachmentPostSchema):
"""
Schema model for the response to an attachment creation request.
Schema model for an attachment's metadata.
"""

id: str = Field(description="ID of the attachment")


class AttachmentPostResponseSchema(CreatedModifiedSchemaMixin, AttachmentMetadataSchema):
"""
Schema model for the response to an attachment creation request.
"""

upload_info: AttachmentPostUploadInfoSchema = Field(
description="Information required to upload the attachment file"
)
20 changes: 18 additions & 2 deletions object_storage_api/services/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@
"""

import logging
from typing import Annotated
from typing import Annotated, Optional

from bson import ObjectId
from fastapi import Depends

from object_storage_api.core.exceptions import InvalidObjectIdError
from object_storage_api.models.attachment import AttachmentIn
from object_storage_api.repositories.attachment import AttachmentRepo
from object_storage_api.schemas.attachment import AttachmentPostResponseSchema, AttachmentPostSchema
from object_storage_api.schemas.attachment import (
AttachmentMetadataSchema,
AttachmentPostResponseSchema,
AttachmentPostSchema,
)
from object_storage_api.stores.attachment import AttachmentStore

logger = logging.getLogger()
Expand Down Expand Up @@ -62,3 +66,15 @@ def create(self, attachment: AttachmentPostSchema) -> AttachmentPostResponseSche
attachment_out = self._attachment_repository.create(attachment_in)

return AttachmentPostResponseSchema(**attachment_out.model_dump(), upload_info=upload_info)

def list(self, entity_id: Optional[str] = None) -> list[AttachmentMetadataSchema]:
"""
Retrieve a list of attachments based on the provided filters.
:param entity_id: The ID of the entity to filter attachments by.
:return: List of attachments or an empty list if no attachments are retrieved.
"""

attachments = self._attachment_repository.list(entity_id)

return [AttachmentMetadataSchema(**attachment.model_dump()) for attachment in attachments]
122 changes: 122 additions & 0 deletions test/e2e/test_attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

from test.mock_data import (
ATTACHMENT_GET_DATA_ALL_VALUES,
ATTACHMENT_POST_DATA_ALL_VALUES,
ATTACHMENT_POST_DATA_REQUIRED_VALUES_ONLY,
ATTACHMENT_POST_RESPONSE_DATA_ALL_VALUES,
Expand All @@ -11,6 +12,7 @@
from typing import Optional

import pytest
from bson import ObjectId
import requests
from fastapi.testclient import TestClient
from httpx import Response
Expand Down Expand Up @@ -132,3 +134,123 @@ def test_create_with_invalid_entity_id(self):

self.post_attachment({**ATTACHMENT_POST_DATA_REQUIRED_VALUES_ONLY, "entity_id": "invalid-id"})
self.check_post_attachment_failed_with_detail(422, "Invalid `entity_id` given")


class ListDSL(CreateDSL):
"""Base class for list tests."""

_get_response_attachment: Response

def get_attachments(self, filters: Optional[dict] = None) -> None:
"""
Gets a list of attachments with the given filters.
:param filters: Filters to use in the request.
"""
self._get_response_attachment = self.test_client.get("/attachments", params=filters)

def post_test_attachments(self) -> list[dict]:
"""
Posts three attachments. The first two attachments have the same entity ID, the last attachment has a different
one.
:return: List of dictionaries containing the expected item data returned from a get endpoint in
the form of an `AttachmentMetadataSchema`.
"""
entity_id_a, entity_id_b = (str(ObjectId()) for _ in range(2))

# First item
attachment_a_id = self.post_attachment(
{
**ATTACHMENT_POST_DATA_ALL_VALUES,
"entity_id": entity_id_a,
},
)

# Second item
attachment_b_id = self.post_attachment(
{
**ATTACHMENT_POST_DATA_ALL_VALUES,
"entity_id": entity_id_a,
},
)

# Third item
attachment_c_id = self.post_attachment(
{
**ATTACHMENT_POST_DATA_ALL_VALUES,
"entity_id": entity_id_b,
},
)

return [
{**ATTACHMENT_GET_DATA_ALL_VALUES, "entity_id": entity_id_a, "id": attachment_a_id},
{**ATTACHMENT_GET_DATA_ALL_VALUES, "entity_id": entity_id_a, "id": attachment_b_id},
{**ATTACHMENT_GET_DATA_ALL_VALUES, "entity_id": entity_id_b, "id": attachment_c_id},
]

def check_get_attachments_success(self, expected_attachments_get_data: list[dict]) -> None:
"""
Checks that a prior call to `get_attachments` gave a successful response with the expected data returned.
:param expected_attachments_get_data: List of dictionaries containing the expected attachment data as would
be required for an `AttachmentMetadataSchema`.
"""
assert self._get_response_attachment.status_code == 200
assert self._get_response_attachment.json() == expected_attachments_get_data

def check_get_attachments_failed_with_message(self, status_code, expected_detail, obtained_detail):
"""Checks the response of listing attachments failed with the expected message."""

assert self._get_response_attachment.status_code == status_code
assert obtained_detail == expected_detail


class TestList(ListDSL):
"""Tests for getting a list of attachments."""

def test_list_with_no_filters(self):
"""
Test getting a list of all attachments with no filters provided.
Posts three attachments and expects all of them to be returned.
"""

attachments = self.post_test_attachments()
self.get_attachments()
self.check_get_attachments_success(attachments)

def test_list_with_entity_id_filter(self):
"""
Test getting a list of all attachments with an `entity_id` filter provided.
Posts three attachments and then filter using the `entity_id`.
"""

attachments = self.post_test_attachments()
self.get_attachments(filters={"entity_id": attachments[0]["entity_id"]})
self.check_get_attachments_success(attachments[:2])

def test_list_with_entity_id_filter_with_no_matching_results(self):
"""
Test getting a list of all attachments with an `entity_id` filter provided.
Posts three attachments and expects no results.
"""

self.post_test_attachments()
self.get_attachments(filters={"entity_id": ObjectId()})
self.check_get_attachments_success([])

def test_list_with_invalid_entity_id_filter(self):
"""
Test getting a list of all attachments with an invalid `entity_id` filter provided.
Posts three attachments and expects a 422 status code.
"""

self.post_test_attachments()
self.get_attachments(filters={"entity_id": False})
self.check_get_attachments_failed_with_message(
422, "Invalid ID given", self._get_response_attachment.json()["detail"]
)
10 changes: 4 additions & 6 deletions test/e2e/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,8 +219,8 @@ def check_get_images_success(self, expected_images_get_data: list[dict]) -> None
assert self._get_response_image.status_code == 200
assert self._get_response_image.json() == expected_images_get_data

def check_images_list_response_failed_with_message(self, status_code, expected_detail, obtained_detail):
"""Checks the response of listing images failed as expected."""
def check_get_images_failed_with_message(self, status_code, expected_detail, obtained_detail):
"""Checks the response of listing images failed with the expected message."""

assert self._get_response_image.status_code == status_code
assert obtained_detail == expected_detail
Expand Down Expand Up @@ -269,9 +269,7 @@ def test_list_with_invalid_entity_id_filter(self):
"""
self.post_test_images()
self.get_images(filters={"entity_id": False})
self.check_images_list_response_failed_with_message(
422, "Invalid ID given", self._get_response_image.json()["detail"]
)
self.check_get_images_failed_with_message(422, "Invalid ID given", self._get_response_image.json()["detail"])

def test_list_with_primary_filter(self):
"""
Expand Down Expand Up @@ -302,7 +300,7 @@ def test_list_with_invalid_primary_filter(self):
"""
self.post_test_images()
self.get_images(filters={"primary": str(ObjectId())})
self.check_images_list_response_failed_with_message(
self.check_get_images_failed_with_message(
422,
"Input should be a valid boolean, unable to interpret input",
self._get_response_image.json()["detail"][0]["msg"],
Expand Down
8 changes: 6 additions & 2 deletions test/mock_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,15 @@
"object_key": "attachments/65df5ee771892ddcc08bd28f/65e0a624d64aaae884abaaee",
}

ATTACHMENT_POST_RESPONSE_DATA_ALL_VALUES = {
ATTACHMENT_GET_DATA_ALL_VALUES = {
**ATTACHMENT_POST_DATA_ALL_VALUES,
"id": ANY,
}

ATTACHMENT_POST_RESPONSE_DATA_ALL_VALUES = {
**ATTACHMENT_GET_DATA_ALL_VALUES,
**CREATED_MODIFIED_GET_DATA_EXPECTED,
**ATTACHMENT_UPLOAD_INFO_POST_RESPONSE_DATA_EXPECTED,
"id": ANY,
}

# ---------------------------- IMAGES -----------------------------
Expand Down
Loading

0 comments on commit 1620e19

Please sign in to comment.