Skip to content

Commit

Permalink
Merge branch 'add-breadcrumbs-endpoints-#87' into replace-paths-with-…
Browse files Browse the repository at this point in the history
…ids-#87
  • Loading branch information
joelvdavies committed Oct 19, 2023
2 parents ca0e2da + 6e3d925 commit d77cb45
Show file tree
Hide file tree
Showing 14 changed files with 1,699 additions and 184 deletions.
33 changes: 33 additions & 0 deletions .github/workflows/.ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,36 @@ jobs:

- name: Run e2e tests
run: DATABASE__NAME="test-ims" pytest test/e2e/ --cov

docker:
# This job triggers only if all the other jobs succeed and does different things depending on the context.
# The job builds the Docker image in all cases and also pushes the image to Harbor only if something is
# pushed to the develop branch.
needs: [linting, unit-tests, e2e-tests]
name: Docker
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0

- name: Login to Harbor
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: harbor.stfc.ac.uk/inventory-management-system
username: ${{ secrets.HARBOR_USERNAME }}
password: ${{ secrets.HARBOR_TOKEN }}

- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
with:
images: harbor.stfc.ac.uk/inventory-management-system/ims-api

- name: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' && 'Build and push Docker image to Harbor' || 'Build Docker image' }}
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
with:
context: .
file: ./Dockerfile.prod
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
38 changes: 38 additions & 0 deletions Dockerfile.prod
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
FROM python:3.10-alpine3.18

WORKDIR /inventory-management-system-api-run

COPY README.md pyproject.toml ./
# Copy inventory_management_system_api source files
COPY inventory_management_system_api/ inventory_management_system_api/

RUN set -eux; \
\
# Install pip dependencies \
python -m pip install --no-cache-dir .; \
\
# Create loging.ini from its .example file \
cp inventory_management_system_api/logging.example.ini inventory_management_system_api/logging.ini; \
\
# Create a non-root user to run as \
addgroup -S inventory-management-system-api; \
adduser -S -D -G inventory-management-system-api -H -h /inventory-management-system-api-run inventory-management-system-api; \
\
# Create a log file \
touch inventory-management-system-api.log; \
# Change ownership of log file - app will need to write to it
chown -R inventory-management-system-api:inventory-management-system-api inventory-management-system-api.log;

USER inventory-management-system-api

ENV API__TITLE="Inventory Management System API"
ENV API__DESCRIPTION="This is the API for the Inventory Management System"
ENV DATABASE__PROTOCOL=mongodb
ENV DATABASE__USERNAME=root
ENV DATABASE__PASSWORD=example
ENV DATABASE__HOSTNAME=localhost
ENV DATABASE__PORT=27017
ENV DATABASE__NAME=ims

CMD ["uvicorn", "inventory_management_system_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
EXPOSE 8000
65 changes: 40 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,65 +2,80 @@

## How to Run

This is a Python microservice created using FastAPI and requires a MongoDB instance to run against. If you are using
Docker to run the application, the `docker-compose.yml file` has already been configured to start a MongoDB instance
that can be accessed at `localhost:27017` using `root` as the username and `example` as the password.
This is a Python microservice created using FastAPI and requires a MongoDB instance to run against.

### Prerequisites
- Docker installed (if you want to run the microservice inside Docker)
- Python 3.10 (or above) and MongoDB 6.0 installed on your machine if you are not using Docker
- [MongoDB Compass](https://www.mongodb.com/products/compass) installed (if you want to interact with the database using a GUI)
- This repository cloned

### Docker Setup
The easiest way to run the application with Docker for local development is using the `docker-compose.yml` file. It is
configured to spin up a MongoDB instance that can be accessed at `localhost:27017` using `root` as the username and
`example` as the password. It also starts the application in a reload mode using the `Dockerfile`. Use the `Dockerfile`
or `Dockerfile.prod` to run just the application itself in a container. The former is for local development and must
not be used in production.

1. Ensure that Docker is installed and running on your machine.
2. Clone the repository and navigate to the project directory:
```bash
git clone [email protected]:ral-facilities/inventory-management-system-api.git
cd inventory-management-system-api
3. Create a `logging.ini` file.
Ensure that Docker is installed and running on your machine before proceeding.

#### Using Docker Compose File
1. Create a `logging.ini` file from the example in the root of the project directory:
```bash
cp logging.example.ini logging.ini
```

4. Build and start the Docker containers:
2. Build and start the Docker containers:
```bash
docker-compose up
```
The microservice should now be running inside Docker at http://localhost:8000. The Swagger UI can be accessed
at http://localhost:8000/docs.
The microservice should now be running inside Docker at http://localhost:8000 and its Swagger UI could be accessed
at http://localhost:8000/docs. A MongoDB instance should also be running at http://localhost:27017.

### Local Setup
#### Using Dockerfiles

1. Build an image using either the `Dockerfile` or `Dockerfile.prod` from the root of the project directory:
```bash
docker build -f Dockerfile.prod -t ims_api_image .
```

1. Clone the repository and navigate to the project directory:
2. Start the container using the image built and map it to port `8000` locally:
```bash
git clone [email protected]:ral-facilities/inventory-management-system-api.git
cd inventory-management-system-api
docker run -p 8000:8000 --name ims_api_container ims_api_image
```
2. Create a Python virtual environment and activate it:
or with values for the environment variables:
```bash
docker run -p 8000:8000 --name ims_api_container --env DATABASE__NAME=test-ims ims_api_image
```
The microservice should now be running inside Docker at http://localhost:8000 and its Swagger UI could be accessed
at http://localhost:8000/docs.

### Local Setup

Ensure that MongoDB is installed and running on your machine before proceeding.

1. Create a Python virtual environment and activate it in the root of the project directory:
```bash
python -m venv venv
source venv/bin/activate
```
3. Install the required dependencies using pip:
2. Install the required dependencies using pip:
```bash
pip install .[dev]
```
4. Create a `.env` file using the `.env.example` file and modify the values inside accordingly.
5. Create a `logging.ini` file using the `logging.example.ini` file and modify it accordingly.
6. Ensure that MongoDB is running locally. If it's not installed, you can follow the official MongoDB installation guide
for your operating system.
7. Start the microservice using Uvicorn from the project directory:
3. Create a `.env` file using the `.env.example` file and modify the values inside accordingly.
4. Create a `logging.ini` file using the `logging.example.ini` file and modify it accordingly.
5. Start the microservice using Uvicorn:
```bash
uvicorn inventory_management_system_api.main:app --log-config inventory_management_system_api/logging.ini --reload
```
The microservice should now be running locally at http://localhost:8000. The Swagger UI can be accessed
at http://localhost:8000/docs.
8. To run the unit tests, run :
6. To run the unit tests, run :
```bash
pytest test/unit/
```
9. To run the e2e tests, ensure that MongoDB is running locally and run:
7. To run the e2e tests, run:
```bash
DATABASE__NAME="test-ims" pytest test/e2e/
```
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ def get_breadcrumbs(self, catalogue_category_id: str) -> BreadcrumbsGetSchema:
graph_lookup_from="catalogue_categories",
)

def update(self, catalogue_category_id: str, catalogue_category: CatalogueCategoryIn):
def update(self, catalogue_category_id: str, catalogue_category: CatalogueCategoryIn) -> CatalogueCategoryOut:
"""
Update a catalogue category by its ID in a MongoDB database.
Expand Down
36 changes: 17 additions & 19 deletions inventory_management_system_api/repositories/catalogue_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

from inventory_management_system_api.core.custom_object_id import CustomObjectId
from inventory_management_system_api.core.database import get_database
from inventory_management_system_api.core.exceptions import DuplicateRecordError
from inventory_management_system_api.models.catalogue_item import CatalogueItemOut, CatalogueItemIn

logger = logging.getLogger()
Expand All @@ -34,15 +33,9 @@ def create(self, catalogue_item: CatalogueItemIn) -> CatalogueItemOut:
"""
Create a new catalogue item in a MongoDB database.
The method checks if a duplicate catalogue item is found within the catalogue category.
:param catalogue_item: The catalogue item to be created.
:return: The created catalogue item.
:raises DuplicateRecordError: If a duplicate catalogue item is found within the catalogue category.
"""
if self._is_duplicate_catalogue_item(str(catalogue_item.catalogue_category_id), catalogue_item.name):
raise DuplicateRecordError("Duplicate catalogue item found within the catalogue category")

logger.info("Inserting the new catalogue item into the database")
result = self._collection.insert_one(catalogue_item.dict())
catalogue_item = self.get(str(result.inserted_id))
Expand All @@ -62,6 +55,23 @@ def get(self, catalogue_item_id: str) -> Optional[CatalogueItemOut]:
return CatalogueItemOut(**catalogue_item)
return None

def update(self, catalogue_item_id: str, catalogue_item: CatalogueItemIn) -> CatalogueItemOut:
"""
Update a catalogue item by its ID in a MongoDB database.
:param catalogue_item_id: The ID of the catalogue item to update.
:param catalogue_item: The catalogue item containing the update data.
:return: The updated catalogue item.
"""
catalogue_item_id = CustomObjectId(catalogue_item_id)
# pylint: disable=fixme
# TODO - (when the relevant item logic is implemented) check if catalogue item has children elements if the
# `catalogue_category_id` is being updated.
logger.info("Updating catalogue item with ID: %s in the database", catalogue_item_id)
self._collection.update_one({"_id": catalogue_item_id}, {"$set": catalogue_item.dict()})
catalogue_item = self.get(str(catalogue_item_id))
return catalogue_item

def list(self, catalogue_category_id: Optional[str]) -> List[CatalogueItemOut]:
"""
Retrieve all catalogue items from a MongoDB.
Expand All @@ -83,15 +93,3 @@ def list(self, catalogue_category_id: Optional[str]) -> List[CatalogueItemOut]:

catalogue_items = self._collection.find(query)
return [CatalogueItemOut(**catalogue_item) for catalogue_item in catalogue_items]

def _is_duplicate_catalogue_item(self, catalogue_category_id: str, name: str) -> bool:
"""
Check if a catalogue item with the same name already exists within the catalogue category.
:param catalogue_category_id: The ID of the catalogue category to check for duplicates in.
:return: `True` if a duplicate catalogue item is found, `False` otherwise.
"""
logger.info("Checking if catalogue item with name '%s' already exists within the category", name)
catalogue_category_id = CustomObjectId(catalogue_category_id)
count = self._collection.count_documents({"catalogue_category_id": catalogue_category_id, "name": name})
return count > 0
56 changes: 45 additions & 11 deletions inventory_management_system_api/routers/v1/catalogue_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@
from inventory_management_system_api.core.exceptions import (
MissingRecordError,
InvalidObjectIdError,
DuplicateRecordError,
NonLeafCategoryError,
InvalidCatalogueItemPropertyTypeError,
MissingMandatoryCatalogueItemProperty,
)
from inventory_management_system_api.schemas.catalogue_item import CatalogueItemSchema, CatalogueItemPostRequestSchema
from inventory_management_system_api.schemas.catalogue_item import (
CatalogueItemSchema,
CatalogueItemPostRequestSchema,
CatalogueItemPatchRequestSchema,
)
from inventory_management_system_api.services.catalogue_item import CatalogueItemService

logger = logging.getLogger()
Expand Down Expand Up @@ -52,18 +55,15 @@ def get_catalogue_item(
) -> CatalogueItemSchema:
# pylint: disable=missing-function-docstring
logger.info("Getting catalogue item with ID: %s", catalogue_item_id)
message = "A catalogue item with such ID was not found"
try:
catalogue_item = catalogue_item_service.get(catalogue_item_id)
if not catalogue_item:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="The requested catalogue item was not found"
)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message)
return CatalogueItemSchema(**catalogue_item.dict())
except InvalidObjectIdError as exc:
logger.exception("The ID is not a valid ObjectId value")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="The requested catalogue item was not found"
) from exc
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message) from exc


@router.post(
Expand All @@ -85,13 +85,47 @@ def create_catalogue_item(
logger.exception(str(exc))
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except (MissingRecordError, InvalidObjectIdError) as exc:
message = "The specified catalogue category ID does not exist in the database"
message = "The specified catalogue category ID does not exist"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=message) from exc
except DuplicateRecordError as exc:
message = "A catalogue item with the same name already exists within the catalogue category"
except NonLeafCategoryError as exc:
message = "Adding a catalogue item to a non-leaf catalogue category is not allowed"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=message) from exc


@router.patch(
path="/{catalogue_item_id}",
summary="Update a catalogue item partially by ID",
response_description="Catalogue item updated successfully",
)
def partial_update_catalogue_item(
catalogue_item: CatalogueItemPatchRequestSchema,
catalogue_item_id: str = Path(description="The ID of the catalogue item to update"),
catalogue_item_service: CatalogueItemService = Depends(),
) -> CatalogueItemSchema:
# pylint: disable=missing-function-docstring
logger.info("Partially updating catalogue item with ID: %s", catalogue_item_id)
logger.debug("Catalogue item data: %s", catalogue_item)
try:
updated_catalogue_item = catalogue_item_service.update(catalogue_item_id, catalogue_item)
return CatalogueItemSchema(**updated_catalogue_item.dict())
except (InvalidCatalogueItemPropertyTypeError, MissingMandatoryCatalogueItemProperty) as exc:
logger.exception(str(exc))
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
except (MissingRecordError, InvalidObjectIdError) as exc:
if (
catalogue_item.catalogue_category_id
and catalogue_item.catalogue_category_id in str(exc)
or "catalogue category" in str(exc).lower()
):
message = "The specified catalogue category ID does not exist"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=message) from exc

message = "A catalogue item with such ID was not found"
logger.exception(message)
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=message) from exc
except NonLeafCategoryError as exc:
message = "Adding a catalogue item to a non-leaf catalogue category is not allowed"
logger.exception(message)
Expand Down
13 changes: 13 additions & 0 deletions inventory_management_system_api/schemas/catalogue_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,19 @@ class CatalogueItemPostRequestSchema(BaseModel):
manufacturer: ManufacturerSchema = Field(description="The details of the manufacturer")


class CatalogueItemPatchRequestSchema(CatalogueItemPostRequestSchema):
"""
Schema model for a catalogue item update request.
"""

catalogue_category_id: Optional[str] = Field(
description="The ID of the catalogue category that the catalogue item belongs to"
)
name: Optional[str] = Field(description="The name of the catalogue item")
description: Optional[str] = Field(description="The catalogue item description")
manufacturer: Optional[ManufacturerSchema] = Field(description="The details of the manufacturer")


class CatalogueItemSchema(CatalogueItemPostRequestSchema):
"""
Schema model for a catalogue item response.
Expand Down
21 changes: 6 additions & 15 deletions inventory_management_system_api/services/catalogue_category.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,25 +118,16 @@ def update(
if not stored_catalogue_category:
raise MissingRecordError(f"No catalogue category found with ID: {catalogue_category_id}")

if "name" in update_data and update_data["name"] != stored_catalogue_category.name:
stored_catalogue_category.name = update_data["name"]
stored_catalogue_category.code = utils.generate_code(stored_catalogue_category.name, "catalogue category")

if "parent_id" in update_data and update_data["parent_id"] != stored_catalogue_category.parent_id:
stored_catalogue_category.parent_id = update_data["parent_id"]
parent_catalogue_category = (
self.get(stored_catalogue_category.parent_id) if stored_catalogue_category.parent_id else None
)
if "name" in update_data and catalogue_category.name != stored_catalogue_category.name:
update_data["code"] = utils.generate_code(catalogue_category.name, "catalogue category")

if "parent_id" in update_data and catalogue_category.parent_id != stored_catalogue_category.parent_id:
parent_catalogue_category = self.get(catalogue_category.parent_id) if catalogue_category.parent_id else None

if parent_catalogue_category and parent_catalogue_category.is_leaf:
raise LeafCategoryError("Cannot add catalogue category to a leaf parent catalogue category")

if "is_leaf" in update_data:
stored_catalogue_category.is_leaf = update_data["is_leaf"]

if "catalogue_item_properties" in update_data:
stored_catalogue_category.catalogue_item_properties = update_data["catalogue_item_properties"]

stored_catalogue_category = stored_catalogue_category.copy(update=update_data)
return self._catalogue_category_repository.update(
catalogue_category_id, CatalogueCategoryIn(**stored_catalogue_category.dict())
)
Loading

0 comments on commit d77cb45

Please sign in to comment.