diff --git a/README.md b/README.md index f163c38..1c5a026 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # NYU DevOps Project Template -[![CI Build](https://github.com/CSCI-GA-2820-SP24-003/shopcarts/actions/workflows/ci.yml/badge.svg)](https://github.com/CSCI-GA-2820-SP24-003/shopcarts/actions/workflows/ci.yml) +[![CI Build](https://github.com/CSCI-GA-2820-SP24-003/shopcarts/actions/workflows/ci.yml/badge.svg)](https://github.com/CSCI-GA-2820-SP24-003/shopcarts/actions/workflows/tdd.yml) [![codecov](https://codecov.io/gh/CSCI-GA-2820-SP24-003/shopcarts/graph/badge.svg?token=MM379ZQE9D)](https://codecov.io/gh/CSCI-GA-2820-SP24-003/shopcarts) [![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) diff --git a/features/steps/shopcarts_steps.py b/features/steps/shopcarts_steps.py index 29b05b6..8d0dc6b 100644 --- a/features/steps/shopcarts_steps.py +++ b/features/steps/shopcarts_steps.py @@ -1,3 +1,4 @@ +# spell: ignore shopcarts shopcart """ Shopcart Steps @@ -19,7 +20,7 @@ def step_impl(context): """ Delete all shopcarts and load new ones """ # List all of the shopcarts and delete them one by one - rest_endpoint = f"{context.base_url}/shopcarts" + rest_endpoint = f"{context.base_url}/api/shopcarts" context.resp = requests.get(rest_endpoint) assert(context.resp.status_code == HTTP_200_OK) for shopcart in context.resp.json(): diff --git a/poetry.lock b/poetry.lock index 0500893..3db58b7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -110,13 +110,13 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "blinker" -version = "1.7.0" +version = "1.8.1" description = "Fast, simple object-to-object and broadcast signaling" optional = false python-versions = ">=3.8" files = [ - {file = "blinker-1.7.0-py3-none-any.whl", hash = "sha256:c3f865d4d54db7abc53758a01601cf343fe55b84c1de4e3fa910e420b438d5b9"}, - {file = "blinker-1.7.0.tar.gz", hash = "sha256:e6820ff6fa4e4d1d8e2747c2283749c3f547e4fee112b98555cdcdae32996182"}, + {file = "blinker-1.8.1-py3-none-any.whl", hash = "sha256:5f1cdeff423b77c31b89de0565cd03e5275a03028f44b2b15f912632a58cced6"}, + {file = "blinker-1.8.1.tar.gz", hash = "sha256:da44ec748222dcd0105ef975eed946da197d5bdf8bafb6aa92f5bc89da63fa25"}, ] [[package]] @@ -449,13 +449,13 @@ doc = ["Sphinx", "sphinx-rtd-theme", "sphinxcontrib-spelling"] [[package]] name = "faker" -version = "24.11.0" +version = "24.14.1" description = "Faker is a Python package that generates fake data for you." optional = false python-versions = ">=3.8" files = [ - {file = "Faker-24.11.0-py3-none-any.whl", hash = "sha256:adb98e771073a06bdc5d2d6710d8af07ac5da64c8dc2ae3b17bb32319e66fd82"}, - {file = "Faker-24.11.0.tar.gz", hash = "sha256:34b947581c2bced340c39b35f89dbfac4f356932cfff8fe893bde854903f0e6e"}, + {file = "Faker-24.14.1-py3-none-any.whl", hash = "sha256:a5edba3aa17a1d689c8907e5b0cd1653079c2466a4807f083aa7b5f80a00225d"}, + {file = "Faker-24.14.1.tar.gz", hash = "sha256:380a3697e696ae4fcf50a93a3d9e0286fab7dfbf05a9caa4421fa4727c6b1e89"}, ] [package.dependencies] diff --git a/service/__init__.py b/service/__init__.py index b25743f..d4d1664 100644 --- a/service/__init__.py +++ b/service/__init__.py @@ -20,13 +20,23 @@ """ import sys from flask import Flask +from flask_restx import Api from service import config from service.common import log_handlers -from flask_restx import Api # Will be initialize when app is created api = None # pylint: disable=invalid-name +# Document the type of authorization required +authorizations = { + "apikey": { + "type": "apiKey", + "in": "header", + "name": "X-Api-Key", + } +} + + ############################################################ # Initialize the Flask instance ############################################################ @@ -36,31 +46,32 @@ def create_app(): app = Flask(__name__) app.config.from_object(config) - # Initialize Plugins - # pylint: disable=import-outside-toplevel - from service.models.persistent_base import db - - ###################################################################### - # Configure Swagger before initializing it - ###################################################################### + app.url_map.strict_slashes = False + + # Initialize the RESTX API global api api = Api( app, version="1.0.0", - title="Shopcart Demo REST API Service", - description="This is a sample server Shopcart server.", - default="Shopcarts", + title="Shopcart REST API Service", + description="This is the REST API for the Shopcart Service", + default="shopcarts", default_label="Shopcart operations", - doc="/apidocs", # default also could use doc='/apidocs/', + doc="/apidocs", + authorizations=authorizations, prefix="/api", ) + # Initialize Plugins + # pylint: disable=import-outside-toplevel + from service.models.persistent_base import db + db.init_app(app) with app.app_context(): # Dependencies require we import the routes AFTER the Flask app is created - # pylint: disable=wrong-import-position, wrong-import-order, unused-import - from service import routes # noqa: F401 E402 + # pylint: disable=wrong-import-position, wrong-import-order, unused-import, cyclic-import + from service import routes, models # noqa: F401 E402 from service.common import error_handlers, cli_commands # noqa: F401, E402 try: diff --git a/service/common/error_handlers.py b/service/common/error_handlers.py index cd1bfaa..4778e36 100644 --- a/service/common/error_handlers.py +++ b/service/common/error_handlers.py @@ -16,18 +16,22 @@ """ Module: error_handlers """ -from flask import jsonify from flask import current_app as app # Import Flask application from service.models import DataValidationError +from service import api from . import status ###################################################################### # Error Handlers ###################################################################### -@app.errorhandler(DataValidationError) +@api.errorhandler(DataValidationError) def request_validation_error(error): """Handles Value Errors from bad data""" - return bad_request(error) - - + message = str(error) + app.logger.error(message) + return { + "status_code": status.HTTP_400_BAD_REQUEST, + "error": "Bad Request", + "message": message, + }, status.HTTP_400_BAD_REQUEST diff --git a/service/models/__init__.py b/service/models/__init__.py index 5267f5b..4c6d972 100644 --- a/service/models/__init__.py +++ b/service/models/__init__.py @@ -9,4 +9,3 @@ # , ShopCartStatus from .shop_cart_item import ShopCartItem - diff --git a/service/models/shop_cart.py b/service/models/shop_cart.py index 235d656..20a41e2 100644 --- a/service/models/shop_cart.py +++ b/service/models/shop_cart.py @@ -48,7 +48,7 @@ def serialize(self) -> dict: "id": self.id, "user_id": self.user_id, "name": self.name, - "total_price": str(self.total_price), + "total_price": float(self.total_price), "status": self.status.name, "items": [], } @@ -149,5 +149,5 @@ def find_by_status(cls, status): Args: status (ShopCartStatus): the status of the ShopCarts you want to match """ - logger.info("Processing status query for %s ...", status.name) + logger.info("Processing status query for %s ...", status) return cls.query.filter(cls.status == status).all() diff --git a/service/models/shop_cart_item.py b/service/models/shop_cart_item.py index 52362db..8f873c9 100644 --- a/service/models/shop_cart_item.py +++ b/service/models/shop_cart_item.py @@ -38,7 +38,7 @@ def serialize(self) -> dict: "name": self.name, "product_id": self.product_id, "quantity": self.quantity, - "price": self.price, + "price": float(self.price), } def deserialize(self, data): diff --git a/service/routes.py b/service/routes.py index fe255ee..ede53fb 100644 --- a/service/routes.py +++ b/service/routes.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. ###################################################################### - +# spell: ignore Rofrano jsonify restx dbname shopcart shopcarts reqparse """ Shop Cart Service @@ -21,14 +21,11 @@ and Delete Shop Carts """ from decimal import Decimal -from flask import jsonify, request, url_for, abort # , request, url_for, abort -from flask import current_app as app # Import Flask application -from flask_restx import Resource, fields, reqparse, inputs -from service.models import ShopCart, ShopCartItem -from service.models.shop_cart import ShopCartStatus +from flask import request, abort, current_app as app +from flask_restx import Resource, fields, reqparse +from service.models.shop_cart import ShopCart, ShopCartItem, ShopCartStatus from service.common import status # HTTP Status Codes -from . import api - +from . import api ##################################################################### @@ -39,26 +36,80 @@ def index(): """Base URL for our service""" return app.send_static_file("index.html") + +############################################################ +# HEALTH CHECK +############################################################ +@app.route("/health") +def health(): + """Health Status""" + return {"status": "OK"}, status.HTTP_200_OK + + # Define the model so that the docs reflect what can be sent -create_model = api.model( +create_item_model = api.model( + "Item", + { + "id": fields.Integer(required=True, description="Id of the shopcart item"), + "shop_cart_id": fields.Integer(required=True, description="Id of the shopcart"), + "name": fields.String(required=True, description="Name of the item"), + "product_id": fields.Integer(required=True, description="Id of the product"), + "quantity": fields.Integer(required=True, description="Quantity of product"), + "price": fields.Float(required=True, description="Price of the product"), + }, +) + +item_model = api.inherit( + "ItemModel", + create_item_model, + { + "id": fields.Integer( + readOnly=True, + description="The Id of the item assigned internally by the service", + ), + "shop_cart_id": fields.Integer( + readOnly=True, + description="The Id of the shopcart to which the item belongs", + ), + }, +) + +# Define the Shopcart model +create_shopcart_model = api.model( "Shopcart", { - "user_id": fields.Integer(required=True, description="The user_id of the Shopcart owner"), - "name": fields.String(required=True, description="The name of the Shopcart"), - "total_price": fields.Float(required=True, description="The total price of the Shopcart"), + "user_id": fields.Integer( + required=True, description="User ID of the shopcart owner" + ), + "name": fields.String(required=True, description="Name of the shopcart"), + "total_price": fields.Float( + required=True, description="Total price of the shopcart" + ), # pylint: disable=protected-access "status": fields.String( - enum=ShopCartStatus._member_names_, description="The status of the Shopcart" - ) + enum=ShopCartStatus._member_names_, + description="Status of the shopcart", + ), + "items": fields.List( + fields.Nested(item_model), + required=False, + description="Items in the shopcart", + ), }, ) shopcart_model = api.inherit( "ShopcartModel", - create_model, + create_shopcart_model, { "id": fields.Integer( - readOnly=True, description="The unique id assigned internally by service" + readOnly=True, + description="The Id of the shopcart assigned internally by the service", + ), + "items": fields.List( + fields.Nested(item_model), + required=False, + description="Items in the shopcart", ), }, ) @@ -68,6 +119,17 @@ def index(): shopcart_args.add_argument( "name", type=str, location="args", required=False, help="List Shopcarts by name" ) +shopcart_args.add_argument( + "status", type=str, location="args", required=False, help="List Shopcarts by status" +) +shopcart_args.add_argument( + "user_id", + type=int, + location="args", + required=False, + help="List Shopcarts by User ID", +) + ###################################################################### # PATH: /shopcarts/{id} @@ -79,9 +141,9 @@ class ShopcartResource(Resource): ShopcartResource class Allows the manipulation of a single Shopcart - GET /shopcart{id} - Returns a Shopcart with the id - PUT /shopcart{id} - Update a Shopcart with the id - DELETE /shopcart{id} - Deletes a Shopcart with the id + GET /shopcarts/{id} - Returns a Shopcart with the id + PUT /shopcarts/{id} - Update a Shopcart with the id + DELETE /shopcarts/{id} - Deletes a Shopcart with the id """ # ------------------------------------------------------------------ @@ -107,7 +169,7 @@ def get(self, shopcart_id): ) return shopcart.serialize(), status.HTTP_200_OK - + # ------------------------------------------------------------------ # UPDATE AN EXISTING SHOPCART # ------------------------------------------------------------------ @@ -158,14 +220,20 @@ def delete(self, shopcart_id): shopcart.delete() app.logger.info("Shopcart with ID: %d delete complete.", shopcart_id) return "", status.HTTP_204_NO_CONTENT - - + + ###################################################################### # PATH: /shopcarts ###################################################################### @api.route("/shopcarts", strict_slashes=False) class ShopcartCollection(Resource): - """Handles all interactions with collections of Shopcarts""" + """ + ShopcartCollection class + + Allows the manipulation of a single Shopcart + GET /shopcarts - Returns a Shopcart list + POST /shopcarts - Creates a new Shopcart + """ # ------------------------------------------------------------------ # LIST ALL SHOPCARTS @@ -181,17 +249,17 @@ def get(self): if args.get("user_id"): app.logger.info("Filtering by user_id: %s", args.get("user_id")) - shop_carts = ShopCart.find_by_user_id(args.get("user_id")).all() + shop_carts = ShopCart.find_by_user_id(args.get("user_id")) elif args.get("name"): app.logger.info("Filtering by name: %s", args.get("name")) shop_carts = ShopCart.find_by_name(args.get("name")).all() elif args.get("status"): app.logger.info("Filtering by status: %s", args.get("status")) - shop_carts = ShopCart.find_by_status(args.get("status")).all() + shop_carts = ShopCart.find_by_status(args.get("status")) else: app.logger.info("Returning unfiltered list.") shop_carts = ShopCart.all() - + app.logger.info("[%s] shopcarts returned", len(shop_carts)) results = [shop_cart.serialize() for shop_cart in shop_carts] return results, status.HTTP_200_OK @@ -201,7 +269,7 @@ def get(self): # ------------------------------------------------------------------ @api.doc("create_shopcarts") @api.response(400, "The posted data was not valid") - @api.expect(create_model) + @api.expect(create_shopcart_model) @api.marshal_with(shopcart_model, code=201) def post(self): """ @@ -219,17 +287,25 @@ def post(self): # Create a message to return app.logger.info("shopcart with new id [%s] created!", shopcart.id) - location_url = api.url_for(ShopcartResource, shopcart_id=shopcart.id, _external=True) - + location_url = api.url_for( + ShopcartResource, shopcart_id=shopcart.id, _external=True + ) + return shopcart.serialize(), status.HTTP_201_CREATED, {"Location": location_url} + ###################################################################### # PATH: /shopcarts/{shopcart_id}/status ###################################################################### @api.route("/shopcarts//status") @api.param("shopcart_id", "The shopcart identifier") class UpdateStatusResource(Resource): - """update a shopcart status""" + """ + UpdateStatusResource class + + Allows the manipulation of a single Shopcart status + PUT /shopcarts/{id}/status - Update status of a Shopcart + """ @api.doc("update_shopcart_status") @api.response(404, "Shopcart not found") @@ -249,7 +325,7 @@ def patch(self, shopcart_id): status.HTTP_404_NOT_FOUND, f"ShopCart with id: '{shopcart_id}' was not found.", ) - + app.logger.debug("Payload = %s", api.payload) data = api.payload if "status" in data: @@ -270,7 +346,12 @@ def patch(self, shopcart_id): @api.route("/shopcarts/status/") @api.param("shopcart_status", "The shopcart status") class FindStatusResource(Resource): - """find shopcarts by status""" + """ + FindStatusResource class + + Allows the manipulation of a single Shopcart + GET /shopcart/status/{name} - Returns a Shopcart list by status + """ @api.doc("find_shopcart_by_status") @api.response(404, "status not found") @@ -326,259 +407,290 @@ def get(self, user_id): return results, status.HTTP_200_OK - # --------------------------------------------------------------------- # I T E M M E T H O D S # --------------------------------------------------------------------- - - ###################################################################### -# CREATE A NEW SHOPCART ITEM +# PATH: /shopcarts/{shopcart_id}/items/{item_id} ###################################################################### -@app.route("/shopcarts//items", methods=["POST"]) -def create_shopcart_item(shopcart_id): - """ - Creates a shop cart item - This endpoint will create a shop cart item and add it to the shopcart - """ - app.logger.info("Request to create an Item for ShopCart with ID: %s", shopcart_id) - check_content_type("application/json") - - # Search for the shopcart - shopcart = ShopCart.find(shopcart_id) - if not shopcart: - abort( - status.HTTP_404_NOT_FOUND, - f"ShopCart with ID '{shopcart_id}' could not be found", - ) - - # Create item from json data - item = ShopCartItem() - item.deserialize(request.get_json()) - - # Append item to the shopcart - # if the item does exists in the shopcart - # change the item quantity by adding one - item_orig = ShopCartItem.find_by_name(item.name) - if item_orig: - item_orig.quantity = item_orig.quantity + item.quantity - item_orig.update() - item = item_orig - - # if the item does not exist in the shopcart - # add a new item - else: - shopcart.items.append(item) - shopcart.update() +@api.route("/shopcarts//items/") +@api.param("shopcart_id", "The Shopcart identifier") +@api.param("item_id", "The Item identifier") +class ItemResource(Resource): + """Handles interactions with Shopcart Items""" - # update the total price - shopcart.update_total_price() + # ------------------------------------------------------------------ + # GET SHOPCART ITEM + # ------------------------------------------------------------------ + @api.doc("get_shopcart_items") + @api.response(404, "Shopcart not found") + @api.response(404, "Item not found") + @api.marshal_with(item_model) + def get(self, shopcart_id, item_id): + """ + Get an Item - # Create a message to return - message = item.serialize() - location_url = url_for( - "get_shopcart_items", shopcart_id=shopcart.id, item_id=item.id, _external=True - ) + This endpoint returns just an item + """ + app.logger.info( + "Request to retrieve Item %s for ShopCart id: %s", (item_id, shopcart_id) + ) - return ( - jsonify(message), - status.HTTP_201_CREATED, - {"Location": location_url}, - ) + # Search for the shopcart + shopcart = ShopCart.find(shopcart_id) + if not shopcart: + abort( + status.HTTP_404_NOT_FOUND, + f"ShopCart with ID '{shopcart_id}' could not be found", + ) + # See if the item exists and abort if it doesn't + item = ShopCartItem.find(item_id) + if not item: + abort( + status.HTTP_404_NOT_FOUND, + f"Item with id '{item_id}' could not be found.", + ) -###################################################################### -# RETRIEVE AN ITEM FROM SHOPCART -###################################################################### -@app.route("/shopcarts//items/", methods=["GET"]) -def get_shopcart_items(shopcart_id, item_id): - """ - Get an Item + return item.serialize(), status.HTTP_200_OK - This endpoint returns just an item - """ - app.logger.info( - "Request to retrieve Item %s for ShopCart id: %s", (item_id, shopcart_id) - ) + # ------------------------------------------------------------------ + # DELETE SHOPCART ITEM + # ------------------------------------------------------------------ + @api.doc("delete_shopcart_items") + @api.response(204, "Item deleted") + @api.response(404, "Shopcart not found") + def delete(self, shopcart_id, item_id): + """ + Delete shopcart item - # Search for the shopcart - shopcart = ShopCart.find(shopcart_id) - if not shopcart: - abort( - status.HTTP_404_NOT_FOUND, - f"ShopCart with ID '{shopcart_id}' could not be found", + This endpoint will delete a shopcart item + """ + app.logger.info( + "Request to delete item %s for a shopcart id: %s", (item_id, shopcart_id) ) - # See if the item exists and abort if it doesn't - item = ShopCartItem.find(item_id) - if not item: - abort( - status.HTTP_404_NOT_FOUND, - f"Item with id '{item_id}' could not be found.", - ) + # Search for the shopcart + shopcart = ShopCart.find(shopcart_id) + if not shopcart: + abort( + status.HTTP_404_NOT_FOUND, + f"ShopCart with ID '{shopcart_id}' could not be found", + ) - return jsonify(item.serialize()), status.HTTP_200_OK + item = ShopCartItem.find(item_id) + if item: + item.delete() + # update the total price + shopcart.update_total_price() -###################################################################### -# RETRIEVE AN ITEM FROM SHOPCART BY PRODUCT ID -###################################################################### -@app.route("/shopcarts//products/", methods=["GET"]) -def get_shopcart_items_by_product_id(shopcart_id, product_id): - """ - Get an Item + return "", status.HTTP_204_NO_CONTENT - This endpoint returns just an item using the product id - """ - app.logger.info( - "Request to retrieve Item %s for ShopCart id: %s", (product_id, shopcart_id) - ) + # ------------------------------------------------------------------ + # UPDATE SHOPCART ITEM + # ------------------------------------------------------------------ + @api.doc("update_shopcart_item") + @api.response(404, "Shopcart not found") + @api.response(404, "Item not found") + @api.response(400, "The Item data was not valid") + @api.response(415, "Invalid header content-type") + @api.expect(item_model) + def put(self, shopcart_id, item_id): + """ + Update an Item - # Search for the shopcart - shopcart = ShopCart.find(shopcart_id) - if not shopcart: - abort( - status.HTTP_404_NOT_FOUND, - f"ShopCart with ID '{shopcart_id}' could not be found", + This endpoint will update an Item based the body that is posted + """ + app.logger.info( + "Request to update Item %s for Shopcart id: %s", (item_id, shopcart_id) ) + check_content_type("application/json") - # See if the item exists and abort if it doesn't - item = ShopCartItem.find_by_product_id(product_id) - if not item: - abort( - status.HTTP_404_NOT_FOUND, - f"Product with id '{product_id}' could not be found.", - ) + # Search for the shopcart + shopcart = ShopCart.find(shopcart_id) + if not shopcart: + abort( + status.HTTP_404_NOT_FOUND, + f"ShopCart with ID '{shopcart_id}' could not be found", + ) - return jsonify(item.serialize()), status.HTTP_200_OK + # See if the address exists and abort if it doesn't + item = ShopCartItem.find(item_id) + if not item: + abort( + status.HTTP_404_NOT_FOUND, + f"Shopcart with id '{item_id}' could not be found.", + ) + + # Update from the json in the body of the request + item.deserialize(api.payload) + item.id = item_id + item.update() + + # update the total price + shopcart.update_total_price() + + return item.serialize(), status.HTTP_200_OK ###################################################################### -# DELETE AN ITEM FROM SHOPCART +# PATH: /shopcarts/{shopcart_id}/items ###################################################################### -@app.route("/shopcarts//items/", methods=["DELETE"]) -def delete_shopcart_items(shopcart_id, item_id): - """ - Delete shopcart item - - This endpoint will delete a shopcart item - """ - app.logger.info( - "Request to delete item %s for a shopcart id: %s", (item_id, shopcart_id) - ) +@api.route("/shopcarts//items", strict_slashes=False) +@api.param("shopcart_id", "The Shopcart identifier") +class ItemCollection(Resource): + """Handles interactions with collections of Shopcart Items""" - # Search for the shopcart - shopcart = ShopCart.find(shopcart_id) - if not shopcart: - abort( - status.HTTP_404_NOT_FOUND, - f"ShopCart with ID '{shopcart_id}' could not be found", + # ------------------------------------------------------------------ + # CREATE SHOPCART ITEM + # ------------------------------------------------------------------ + @api.doc("create_shopcart_item") + @api.response(400, "Invalid shopcart item request body") + @api.response(404, "Shopcart not found") + @api.marshal_with(item_model, code=201) + def post(self, shopcart_id): + """ + Creates a shop cart item + This endpoint will create a shop cart item and add it to the shopcart + """ + app.logger.info( + "Request to create an Item for ShopCart with ID: %s", shopcart_id ) + check_content_type("application/json") - item = ShopCartItem.find(item_id) - if item: - item.delete() + # Search for the shopcart + shopcart = ShopCart.find(shopcart_id) + if not shopcart: + abort( + status.HTTP_404_NOT_FOUND, + f"ShopCart with ID '{shopcart_id}' could not be found", + ) + + # Create item from json data + item = ShopCartItem() + item.deserialize(api.payload) + + # Append item to the shopcart + # if the item does exists in the shopcart + # change the item quantity by adding one + item_orig = ShopCartItem.find_by_name(item.name) + if item_orig: + item_orig.quantity = item_orig.quantity + item.quantity + item_orig.update() + item = item_orig + + # if the item does not exist in the shopcart + # add a new item + else: + shopcart.items.append(item) + shopcart.update() # update the total price shopcart.update_total_price() - return "", status.HTTP_204_NO_CONTENT - - -###################################################################### -# UPDATE A SHOPCART ITEM -###################################################################### -@app.route("/shopcarts//items/", methods=["PUT"]) -def update_shopcart_item(shopcart_id, item_id): - """ - Update an Item - - This endpoint will update an Item based the body that is posted - """ - app.logger.info( - "Request to update Item %s for Shopcart id: %s", (item_id, shopcart_id) - ) - check_content_type("application/json") - - # Search for the shopcart - shopcart = ShopCart.find(shopcart_id) - if not shopcart: - abort( - status.HTTP_404_NOT_FOUND, - f"ShopCart with ID '{shopcart_id}' could not be found", + # Create a message to return + location_url = api.url_for( + ItemResource, shopcart_id=shopcart.id, item_id=item.id, _external=True ) - # See if the address exists and abort if it doesn't - item = ShopCartItem.find(item_id) - if not item: - abort( - status.HTTP_404_NOT_FOUND, - f"Shopcart with id '{item_id}' could not be found.", + return ( + item.serialize(), + status.HTTP_201_CREATED, + {"Location": location_url}, ) - # Update from the json in the body of the request - item.deserialize(request.get_json()) - item.id = item_id - item.update() + # ------------------------------------------------------------------ + # LIST SHOPCART ITEMS + # ------------------------------------------------------------------ + @api.doc("list_shopcart_items") + @api.response(404, "Shopcart not found") + @api.marshal_list_with(item_model) + def get(self, shopcart_id): + """ + List all Items in a ShopCart + + This endpoint returns all items within a specified shopcart. + """ + app.logger.info("Request to list items for ShopCart id: %s", shopcart_id) - # update the total price - shopcart.update_total_price() + # Attempting to find the shopcart first to verify it exists + shopcart = ShopCart.find(shopcart_id) + if not shopcart: + abort( + status.HTTP_404_NOT_FOUND, + f"ShopCart with id '{shopcart_id}' could not be found.", + ) - return jsonify(item.serialize()), status.HTTP_200_OK + # Getting query parameters + name = request.args.get("name") + min_price = request.args.get("min_price") + max_price = request.args.get("max_price") + + # pylint: disable=too-many-boolean-expressions + filtered_items = [] + for item in shopcart.items: + if ( + (not name or item.name == name) + and (not min_price or item.price >= Decimal(min_price)) + and (not max_price or item.price <= Decimal(max_price)) + ): + filtered_items.append(item) + + # items = ShopCartItem.find_by_shopcart_id(shopcart_id) + if not filtered_items: + return [], status.HTTP_200_OK + + results = [item.serialize() for item in filtered_items] + app.logger.info("Returning %d items", len(results)) + return results, status.HTTP_200_OK ###################################################################### -# LIST ITEMS IN A SHOPCART +# PATH: /shopcarts/{shopcart_id}/products/{product_id} ###################################################################### -@app.route("/shopcarts//items", methods=["GET"]) -def list_shopcart_items(shopcart_id): - """ - List all Items in a ShopCart - - This endpoint returns all items within a specified shopcart. - """ - app.logger.info("Request to list items for ShopCart id: %s", shopcart_id) - - # Attempting to find the shopcart first to verify it exists - shopcart = ShopCart.find(shopcart_id) - if not shopcart: - abort( - status.HTTP_404_NOT_FOUND, - f"ShopCart with id '{shopcart_id}' could not be found.", - ) - - # Getting query parameters - name = request.args.get("name") - min_price = request.args.get("min_price") - max_price = request.args.get("max_price") +@api.route( + "/shopcarts//products/", strict_slashes=False +) +@api.param("shopcart_id", "The Shopcart identifier") +@api.param("product_id", "The Product identifier") +class ProductResource(Resource): + """Handles interactions with Product Items""" - # pylint: disable=too-many-boolean-expressions - filtered_items = [] - for item in shopcart.items: - if ( - (not name or item.name == name) - and (not min_price or item.price >= Decimal(min_price)) - and (not max_price or item.price <= Decimal(max_price)) - ): - filtered_items.append(item) + # ------------------------------------------------------------------ + # GET SHOPCART ITEM by PRODUCT ID + # ------------------------------------------------------------------ + @api.doc("get_shopcart_items_by_product_id") + @api.response(404, "Shopcart not found") + @api.response(404, "Item not found") + @api.marshal_with(item_model) + def get(self, shopcart_id, product_id): + """ + Get an Item - # items = ShopCartItem.find_by_shopcart_id(shopcart_id) - if not filtered_items: - return jsonify([]), status.HTTP_200_OK + This endpoint returns just an item using the product id + """ + app.logger.info( + "Request to retrieve Item %s for ShopCart id: %s", (product_id, shopcart_id) + ) - results = [item.serialize() for item in filtered_items] - app.logger.info("Returning %d items", len(results)) - return jsonify(results), status.HTTP_200_OK + # Search for the shopcart + shopcart = ShopCart.find(shopcart_id) + if not shopcart: + abort( + status.HTTP_404_NOT_FOUND, + f"ShopCart with ID '{shopcart_id}' could not be found", + ) + # See if the item exists and abort if it doesn't + item = ShopCartItem.find_by_product_id(product_id) + if not item: + abort( + status.HTTP_404_NOT_FOUND, + f"Product with id '{product_id}' could not be found.", + ) -###################################################################### -# health check -###################################################################### -@app.route("/health", methods=["GET"]) -def read_health(): - """Endpoint for health check. - Returns the health status of the application.""" - return jsonify({"status": "OK"}), status.HTTP_200_OK + return item.serialize(), status.HTTP_200_OK ###################################################################### diff --git a/service/static/js/rest_api.js b/service/static/js/rest_api.js index 45ab038..c81d93c 100644 --- a/service/static/js/rest_api.js +++ b/service/static/js/rest_api.js @@ -1,5 +1,7 @@ $(function () { + let SHOPCART_SERVICE_BASE_URL = "/api/shopcarts"; + // **************************************** // U T I L I T Y F U N C T I O N S // **************************************** @@ -52,7 +54,7 @@ $(function () { let ajax = $.ajax({ type: "POST", - url: "/shopcarts", + url: SHOPCART_SERVICE_BASE_URL, contentType: "application/json", data: JSON.stringify(data), }); @@ -92,7 +94,7 @@ $(function () { let ajax = $.ajax({ type: "PUT", - url: `/shopcarts/${shopcart_id}`, + url: `${SHOPCART_SERVICE_BASE_URL}/${shopcart_id}`, contentType: "application/json", data: JSON.stringify(data) }) @@ -120,7 +122,7 @@ $(function () { let ajax = $.ajax({ type: "GET", - url: `/shopcarts/${shopcart_id}`, + url: `${SHOPCART_SERVICE_BASE_URL}/${shopcart_id}`, contentType: "application/json", data: '' }) @@ -150,7 +152,7 @@ $(function () { let ajax = $.ajax({ type: "DELETE", - url: `/shopcarts/${shopcart_id}`, + url: `${SHOPCART_SERVICE_BASE_URL}/${shopcart_id}`, contentType: "application/json", data: '', }) @@ -213,7 +215,7 @@ $(function () { let ajax = $.ajax({ type: "GET", - url: `/shopcarts?${queryString}`, + url: `${SHOPCART_SERVICE_BASE_URL}?${queryString}`, contentType: "application/json", data: '' }) diff --git a/tests/factories.py b/tests/factories.py index f1f15fe..d8800c3 100644 --- a/tests/factories.py +++ b/tests/factories.py @@ -1,22 +1,26 @@ """Test Factory""" +from decimal import Decimal +import random import factory from factory.fuzzy import FuzzyDecimal, FuzzyChoice from service.models import ShopCart, ShopCartItem from service.models.shop_cart import ShopCartStatus -from decimal import Decimal -import random class DecimalMine(factory.fuzzy.BaseFuzzyAttribute): - def __init__(self, low, high, precision='0.01'): + """Generates a random decimal number between low and high with precision.""" + + def __init__(self, low, high, precision="0.01"): super().__init__() self.low = Decimal(low) self.high = Decimal(high) self.precision = Decimal(precision) def fuzz(self): - value = Decimal(random.uniform(float(self.low), float(self.high))).quantize(self.precision) + value = Decimal(random.uniform(float(self.low), float(self.high))).quantize( + self.precision + ) return value @@ -44,7 +48,6 @@ def items( """Creates the Shop Cart Items list""" if not create: return - if extracted: self.items = extracted diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 1dd5144..906e461 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1,10 +1,12 @@ """ CLI Command Extensions for Flask """ + import os from unittest import TestCase from unittest.mock import patch, MagicMock from click.testing import CliRunner + # pylint: disable=unused-import from wsgi import app # noqa: F401 from service.common.cli_commands import db_create # noqa: E402 @@ -16,7 +18,7 @@ class TestFlaskCLI(TestCase): def setUp(self): self.runner = CliRunner() - @patch('service.common.cli_commands.db') + @patch("service.common.cli_commands.db") def test_db_create(self, db_mock): """It should call the db-create command""" db_mock.return_value = MagicMock() diff --git a/tests/test_routes.py b/tests/test_routes.py index 5372388..dc547ec 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -4,14 +4,12 @@ import os import logging -from decimal import Decimal from unittest import TestCase from wsgi import app from service.common import status -from service.models import db, ShopCart +from service.models import db, ShopCart, ShopCartItem from service.models.shop_cart import ShopCartStatus from .factories import ShopCartFactory, ShopCartItemFactory -from decimal import Decimal, ROUND_DOWN DATABASE_URI = os.getenv( @@ -19,7 +17,7 @@ ) BASE_URL = "/api/shopcarts" -BASE_URL_ITEM = "/shopcarts" +BASE_URL_ITEM = "/api/shopcarts" CONTENT_TYPE_JSON = "application/json" MAX_NUM = 99999 @@ -51,6 +49,7 @@ def setUp(self): """Runs before each test""" self.client = app.test_client() db.session.query(ShopCart).delete() # clean up the last tests + db.session.query(ShopCartItem).delete() # clean up the last tests db.session.commit() def tearDown(self): @@ -106,25 +105,25 @@ def test_create_shopcart(self): ) self.assertEqual(new_shopcart["name"], shopcart.name, "name does not match") self.assertEqual( - new_shopcart["total_price"], + float(new_shopcart["total_price"]), float(shopcart.total_price), "total_price does not match", ) # Check that the location header was correct by getting it - # resp = self.client.get(location, content_type="application/json") - # self.assertEqual(resp.status_code, status.HTTP_200_OK) - # new_shopcart = resp.get_json() - # self.assertEqual(new_shopcart["name"], shopcart.name, "Name does not match") - # self.assertEqual( - # new_shopcart["user_id"], shopcart.user_id, "user_id does not match" - # ) - # self.assertEqual( - # float(new_shopcart["total_price"]), - # float(shopcart.total_price), - # "total_price does not match", - # ) - # self.assertEqual(new_shopcart["items"], shopcart.items, "items does not match") + resp = self.client.get(location, content_type="application/json") + self.assertEqual(resp.status_code, status.HTTP_200_OK) + new_shopcart = resp.get_json() + self.assertEqual(new_shopcart["name"], shopcart.name, "Name does not match") + self.assertEqual( + new_shopcart["user_id"], shopcart.user_id, "user_id does not match" + ) + self.assertEqual( + float(new_shopcart["total_price"]), + float(shopcart.total_price), + "total_price does not match", + ) + self.assertEqual(new_shopcart["items"], shopcart.items, "items does not match") def test_get_shopcart_by_name(self): """It should Get a shopcart by Name""" @@ -182,7 +181,8 @@ def test_update_shopcart(self): "user_id" ], # Assuming user_id can be updated or is needed for identification "name": "Updated Name", - "total_price": Decimal(new_shopcart["total_price"]) + Decimal('100'), # Example of updating the price + "total_price": new_shopcart["total_price"] + + 100, # Example of updating the price # Include updates to other fields here "status": new_shopcart["status"], } @@ -196,9 +196,9 @@ def test_update_shopcart(self): # Verify that all fields have been updated correctly self.assertEqual(updated_shopcart["name"], update_payload["name"]) - expected_price = update_payload["total_price"].quantize(Decimal('.001'), rounding=ROUND_DOWN) - actual_price = Decimal(updated_shopcart["total_price"]).quantize(Decimal('.001'), rounding=ROUND_DOWN) - self.assertEqual(actual_price, expected_price) + self.assertEqual( + updated_shopcart["total_price"], float(update_payload["total_price"]) + ) def test_update_shop_cart_with_invalid_fields(self): """It should not update a shopcart with invalid fields and maintain required fields""" @@ -405,7 +405,7 @@ def test_create_shopcart_item(self): self.assertEqual(data["product_id"], item.product_id) self.assertEqual(data["shop_cart_id"], shop_cart.id) self.assertEqual(data["quantity"], item.quantity) - self.assertEqual(data["price"], str(item.price)) + self.assertEqual(data["price"], float(item.price)) def test_create_shopcart_duplicate_items(self): """when adding an item to a shop cart, @@ -462,7 +462,7 @@ def test_get_shopcart_item(self): self.assertEqual(data["name"], item.name) self.assertEqual(data["product_id"], item.product_id) self.assertEqual(data["quantity"], item.quantity) - self.assertEqual(data["price"], str(item.price)) + self.assertEqual(data["price"], float(item.price)) def test_get_shopcart_item_when_no_shopcart(self): """It should Get an error when a shopcart id does not exist @@ -517,7 +517,7 @@ def test_get_shopcart_item_by_product_id(self): self.assertEqual(data["name"], item.name) self.assertEqual(data["product_id"], item.product_id) self.assertEqual(data["quantity"], item.quantity) - self.assertEqual(data["price"], str(item.price)) + self.assertEqual(data["price"], float(item.price)) # when shopcart does not exist resp = self.client.get( @@ -776,6 +776,7 @@ def test_list_shopcart_item_with_no_shopcart(self): def test_list_shopcart_item_with_no_item(self): """It should raise no items not found sign""" shopcart = self._create_shopcarts(1)[0] + print(shopcart.id) resp = self.client.get( f"{BASE_URL}/{shopcart.id}/items", content_type="application/json", @@ -825,7 +826,7 @@ def test_shopcart_total_price_with_new_item(self): data = resp.get_json() self.assertEqual( data["total_price"], - str(item_1.price * item_1.quantity + item_2.price * item_2.quantity), + float(item_1.price * item_1.quantity + item_2.price * item_2.quantity), ) def test_shopcart_total_price_with_delete_item(self): @@ -873,7 +874,7 @@ def test_shopcart_total_price_with_delete_item(self): ) self.assertEqual(resp.status_code, status.HTTP_200_OK) data = resp.get_json() - self.assertEqual(data["total_price"], str(item_2.price * item_2.quantity)) + self.assertEqual(data["total_price"], float(item_2.price * item_2.quantity)) def test_shopcart_total_price_with_update_item(self): """ @@ -909,7 +910,7 @@ def test_shopcart_total_price_with_update_item(self): ) self.assertEqual(resp.status_code, status.HTTP_200_OK) data = resp.get_json() - self.assertEqual(data["total_price"], str(item.price * 3)) + self.assertEqual(data["total_price"], float(item.price * 3)) def test_read_health(self): """It should return 200 status and OK diff --git a/tests/test_shop_cart.py b/tests/test_shop_cart.py index bfd375d..a2bf7e2 100644 --- a/tests/test_shop_cart.py +++ b/tests/test_shop_cart.py @@ -1,3 +1,4 @@ +# spell: ignore shopcart shopcarts psycopg testdb """ Test cases for ShopCart Model """ @@ -165,21 +166,21 @@ def test_list_all_shop_carts(self): def test_serialize_shop_cart(self): """It should serialize a Shop Cart""" shop_cart = ShopCartFactory() - shop_cart_item = ShopCartItem() + shop_cart_item = ShopCartItemFactory() shop_cart.items.append(shop_cart_item) data = shop_cart.serialize() self.assertNotEqual(data, None) self.assertEqual(data["id"], shop_cart.id) self.assertEqual(data["user_id"], shop_cart.user_id) self.assertEqual(data["name"], shop_cart.name) - self.assertEqual(data["total_price"], str(shop_cart.total_price)) + self.assertEqual(data["total_price"], float(shop_cart.total_price)) self.assertEqual(len(data["items"]), 1) items = data["items"] self.assertEqual(items[0]["id"], shop_cart_item.id) self.assertEqual(items[0]["product_id"], shop_cart_item.product_id) self.assertEqual(items[0]["shop_cart_id"], shop_cart_item.shop_cart_id) self.assertEqual(items[0]["quantity"], shop_cart_item.quantity) - self.assertEqual(items[0]["price"], shop_cart_item.price) + self.assertEqual(items[0]["price"], float(shop_cart_item.price)) def test_deserialize_shop_cart(self): """It should deserialize a Shop Cart""" diff --git a/tests/test_shop_cart_item.py b/tests/test_shop_cart_item.py index 30bd84f..31f4670 100644 --- a/tests/test_shop_cart_item.py +++ b/tests/test_shop_cart_item.py @@ -125,7 +125,7 @@ def test_serialize_shop_cart_item(self): self.assertEqual(data["product_id"], shop_cart_item.product_id) self.assertEqual(data["shop_cart_id"], shop_cart_item.shop_cart_id) self.assertEqual(data["quantity"], shop_cart_item.quantity) - self.assertEqual(data["price"], shop_cart_item.price) + self.assertEqual(data["price"], float(shop_cart_item.price)) def test_deserialize_shop_cart_item(self): """It should deserialize a Shop Cart Item""" @@ -137,7 +137,7 @@ def test_deserialize_shop_cart_item(self): self.assertEqual(shop_cart_item.shop_cart_id, data.shop_cart_id) self.assertEqual(shop_cart_item.product_id, data.product_id) self.assertEqual(shop_cart_item.quantity, data.quantity) - self.assertEqual(shop_cart_item.price, data.price) + self.assertEqual(shop_cart_item.price, float(data.price)) # # # + + + + + + + + + + + + + SAD PATHS + + + + + + + + + + + + + + + def test_deserialize_missing_data(self):