Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add update endpoint without primary for images #35 #75

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Open
23 changes: 23 additions & 0 deletions object_storage_api/repositories/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,29 @@ def list(self, entity_id: Optional[str], primary: Optional[bool], session: Clien
images = self._images_collection.find(query, session=session)
return [ImageOut(**image) for image in images]

def update(self, image_id: str, image: ImageIn, session: ClientSession = None) -> ImageOut:
"""
Updates an image from a MongoDB database.

:param image_id: The ID of the image to update.
:param image: The image containing the update data.
:param session: PyMongo ClientSession to use for database operations.
:return: The updated image.
:raises InvalidObjectIdError: If the supplied `image_id` is invalid.
"""

logger.info("Updating image metadata with ID: %s", image_id)
try:
image_id = CustomObjectId(image_id)
self._images_collection.update_one(
{"_id": image_id}, {"$set": image.model_dump(by_alias=True)}, session=session
)
except InvalidObjectIdError as exc:
exc.status_code = 404
exc.response_detail = "Image not found"
raise exc
return self.get(image_id=str(image_id), session=session)

def delete(self, image_id: str, session: ClientSession = None) -> None:
"""
Delete an image by its ID from a MongoDB database.
Expand Down
24 changes: 23 additions & 1 deletion object_storage_api/routers/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,12 @@

from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile, status

from object_storage_api.schemas.image import ImageMetadataSchema, ImagePostMetadataSchema, ImageSchema
from object_storage_api.schemas.image import (
ImageMetadataSchema,
ImagePatchMetadataSchema,
ImagePostMetadataSchema,
ImageSchema,
)
from object_storage_api.services.image import ImageService

logger = logging.getLogger()
Expand Down Expand Up @@ -80,6 +85,23 @@ def get_image(
return image_service.get(image_id)


@router.patch(
path="/{image_id}",
summary="Update an image partially by ID",
response_description="Image updated successfully",
)
def partial_update_image(
image: ImagePatchMetadataSchema,
image_id: Annotated[str, Path(description="ID of the image to update")],
image_service: ImageServiceDep,
) -> ImageMetadataSchema:
# pylint: disable=missing-function-docstring
logger.info("Partially updating image with ID: %s", image_id)
logger.debug("Image data: %s", image)

return image_service.update(image_id, image)


@router.delete(
path="/{image_id}",
summary="Delete an image by ID",
Expand Down
10 changes: 9 additions & 1 deletion object_storage_api/schemas/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,20 @@
from object_storage_api.schemas.mixins import CreatedModifiedSchemaMixin


class ImagePatchMetadataSchema(BaseModel):
"""Base schema model for an image."""

title: Optional[str] = Field(default=None, description="Title of the image")
description: Optional[str] = Field(default=None, description="Description of the image")
file_name: Optional[str] = Field(default=None, description="File name of the image")


class ImagePostMetadataSchema(BaseModel):
"""Base schema model for an image."""

entity_id: str = Field(description="ID of the entity the image relates to")
title: Optional[str] = Field(default=None, description="Title of the image")
description: Optional[str] = Field(default=None, description="Description of the image")
entity_id: str = Field(description="ID of the entity the image relates to")


class ImageMetadataSchema(CreatedModifiedSchemaMixin, ImagePostMetadataSchema):
Expand Down
23 changes: 22 additions & 1 deletion object_storage_api/services/image.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
from object_storage_api.core.image import generate_thumbnail_base64_str
from object_storage_api.models.image import ImageIn
from object_storage_api.repositories.image import ImageRepo
from object_storage_api.schemas.image import ImageMetadataSchema, ImagePostMetadataSchema, ImageSchema
from object_storage_api.schemas.image import (
ImageMetadataSchema,
ImagePatchMetadataSchema,
ImagePostMetadataSchema,
ImageSchema,
)
from object_storage_api.stores.image import ImageStore

logger = logging.getLogger()
Expand Down Expand Up @@ -97,6 +102,22 @@ def list(self, entity_id: Optional[str] = None, primary: Optional[bool] = None)
images = self._image_repository.list(entity_id, primary)
return [ImageMetadataSchema(**image.model_dump()) for image in images]

def update(self, image_id: str, image: ImagePatchMetadataSchema) -> ImageMetadataSchema:
"""
Update an image based on its ID.

:param image_id: The ID of the image to update.
:param image: The image containing the fields to be updated.
:return: The updated image.
"""
stored_image = self._image_repository.get(image_id=image_id)
update_data = image.model_dump(exclude_unset=True)
updated_image = self._image_repository.update(
image_id=image_id, image=ImageIn(**{**stored_image.model_dump(), **update_data})
)

return ImageMetadataSchema(**updated_image.model_dump())

def delete(self, image_id: str) -> None:
"""
Delete an image by its ID.
Expand Down
68 changes: 63 additions & 5 deletions test/e2e/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

from test.mock_data import (
IMAGE_GET_DATA_ALL_VALUES,
IMAGE_GET_METADATA_ALL_VALUES,
IMAGE_GET_METADATA_ALL_VALUES_A,
IMAGE_GET_METADATA_ALL_VALUES_B,
IMAGE_GET_METADATA_REQUIRED_VALUES_ONLY,
IMAGE_PATCH_METADATA_DATA_ALL_VALUES_B,
IMAGE_POST_METADATA_DATA_ALL_VALUES,
IMAGE_POST_METADATA_DATA_REQUIRED_VALUES_ONLY,
)
Expand Down Expand Up @@ -84,7 +86,7 @@ def test_create_with_all_values_provided(self):
"""Test creating an image with all values provided."""

self.post_image(IMAGE_POST_METADATA_DATA_ALL_VALUES, "image.jpg")
self.check_post_image_success(IMAGE_GET_METADATA_ALL_VALUES)
self.check_post_image_success(IMAGE_GET_METADATA_ALL_VALUES_A)

def test_create_with_invalid_entity_id(self):
"""Test creating an image with an invalid `entity_id`."""
Expand Down Expand Up @@ -193,17 +195,17 @@ def post_test_images(self) -> list[dict]:

return [
{
**IMAGE_GET_METADATA_ALL_VALUES,
**IMAGE_GET_METADATA_ALL_VALUES_A,
"entity_id": entity_id_a,
"id": image_a_id,
},
{
**IMAGE_GET_METADATA_ALL_VALUES,
**IMAGE_GET_METADATA_ALL_VALUES_A,
"entity_id": entity_id_a,
"id": image_b_id,
},
{
**IMAGE_GET_METADATA_ALL_VALUES,
**IMAGE_GET_METADATA_ALL_VALUES_A,
"entity_id": entity_id_b,
"id": image_c_id,
},
Expand Down Expand Up @@ -307,6 +309,62 @@ def test_list_with_invalid_primary_filter(self):
)


class UpdateDSL(ListDSL):
"""Base class for update tests."""

_patch_response_image: Response

def patch_image(self, image_id: str, image_patch_data: dict) -> None:
"""
Patches an image with the given ID.

:param image_id: ID of the image to be updated.
:param image_patch_data: Dictionary containing the image patch data as would be required for an
`ImagePatchSchema`.
"""
self._patch_response_image = self.test_client.patch(f"/images/{image_id}", json=image_patch_data)

def check_patch_image_success(self, expected_image_get_data: dict) -> None:
"""
Checks that a prior call to `patch_image` gave a successful response with the expected data returned.

:param expected_image_get_data: Dictionaries containing the expected image data as would be
required for an `ImageMetadataSchema`.
"""
assert self._patch_response_image.status_code == 200
assert self._patch_response_image.json() == expected_image_get_data

def check_patch_image_failed_with_detail(self, status_code: int, detail: str) -> None:
"""
Checks that prior call to `patch_image` gave a failed response with the expected code and detail.

:param status_code: Expected status code to be returned.
:param detail: Expected detail to be returned.
"""
assert self._patch_response_image.status_code == status_code
assert self._patch_response_image.json()["detail"] == detail


class TestUpdate(UpdateDSL):
"""Tests for updating an image."""

def test_partial_update_all_fields(self):
"""Test updating every field of an image."""
image_id = self.post_image(IMAGE_POST_METADATA_DATA_ALL_VALUES, "image.jpg")
self.patch_image(image_id, IMAGE_PATCH_METADATA_DATA_ALL_VALUES_B)
self.check_patch_image_success(IMAGE_GET_METADATA_ALL_VALUES_B)

def test_partial_update_with_non_existent_id(self):
"""Test updating a non-existent image."""
self.patch_image(str(ObjectId()), {})
self.check_patch_image_failed_with_detail(404, "Image not found")

def test_partial_update_invalid_id(self):
"""Test updating an image with an invalid ID."""
self.patch_image("invalid-id", {})
self.check_patch_image_failed_with_detail(404, "Image not found")


class DeleteDSL(ListDSL):
"""Base class for delete tests."""

Expand Down
20 changes: 16 additions & 4 deletions test/mock_data.py
rowan04 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,23 @@
"url": ANY,
}

IMAGE_PATCH_METADATA_DATA_ALL_VALUES_A = {
"title": "Report Title",
"description": "A damage report.",
}

IMAGE_PATCH_METADATA_DATA_ALL_VALUES_B = {
"title": "Shattered Laser",
"description": "An image of a shattered laser.",
"file_name": "picture.jpg",
}

IMAGE_POST_METADATA_DATA_ALL_VALUES = {
**IMAGE_POST_METADATA_DATA_REQUIRED_VALUES_ONLY,
"title": "Report Title",
"description": "A damage report.",
**IMAGE_PATCH_METADATA_DATA_ALL_VALUES_A,
}


IMAGE_IN_DATA_ALL_VALUES = {
**IMAGE_POST_METADATA_DATA_ALL_VALUES,
"id": str(ObjectId()),
Expand All @@ -146,7 +156,7 @@
"thumbnail_base64": "UklGRjQAAABXRUJQVlA4ICgAAADQAQCdASoCAAEAAUAmJYwCdAEO/gOOAAD+qlQWHDxhNJOjVlqIb8AA",
}

IMAGE_GET_METADATA_ALL_VALUES = {
IMAGE_GET_METADATA_ALL_VALUES_A = {
**IMAGE_POST_METADATA_DATA_ALL_VALUES,
**CREATED_MODIFIED_GET_DATA_EXPECTED,
"id": ANY,
Expand All @@ -155,7 +165,9 @@
"thumbnail_base64": "UklGRjQAAABXRUJQVlA4ICgAAADQAQCdASoCAAEAAUAmJYwCdAEO/gOOAAD+qlQWHDxhNJOjVlqIb8AA",
}

IMAGE_GET_METADATA_ALL_VALUES_B = {**IMAGE_GET_METADATA_ALL_VALUES_A, **IMAGE_PATCH_METADATA_DATA_ALL_VALUES_B}
rowan04 marked this conversation as resolved.
Show resolved Hide resolved

IMAGE_GET_DATA_ALL_VALUES = {
**IMAGE_GET_METADATA_ALL_VALUES,
**IMAGE_GET_METADATA_ALL_VALUES_A,
"url": ANY,
}
Loading
Loading