diff --git a/README.md b/README.md index 14497d2..f55e75b 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP ### Endpoints #### Fetch products using name, category, subcategory -- [GET] `/product/` - Get product with name: `name`

+- [GET] `/products?name=` - Get product with name: `name`

- [GET] `/subcategories//products?page=` - Get product with within subcategory `subcategory`. Returns `page_no` of the paginated results.

- [GET] `/categories//products` - Get product with within category `category`. Returns first page of the paginated results.

- [GET] `/categories//products?page=` - Get product with within category `category`. Returns `page_no` of the paginated results.

@@ -151,11 +151,11 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP #### Product - [GET] `/products` - Get all products -- [GET] `/product/(int: product_id)` - Get product with product_id -- [GET] `/product/(int: product_id)/subcategories` - Get subcategories related to product_id -- [DELETE] `/product/(int: product_id)` (Protected) - Delete product with product_id +- [GET] `/products/(int: product_id)` - Get product with product_id +- [GET] `/products/(int: product_id)/subcategories` - Get subcategories related to product_id +- [DELETE] `/products/(int: product_id)` (Protected) - Delete product with product_id -- [POST] `/product/create` (Protected) - Create a new product +- [POST] `/products` (Protected) - Create a new product ``` { "name": "name", @@ -164,7 +164,7 @@ Test the API using Swagger UI (`/` route), Postman, cURL or your preferred HTTP } ``` -- [PUT] `/product/(int: product_id)/update` (Protected) - Update product with product_id +- [PUT] `/products/(int: product_id)` (Protected) - Update product with product_id ``` { "name": "name", diff --git a/app/__init__.py b/app/__init__.py index 0f4d626..5d3dc88 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -14,9 +14,11 @@ def register_blueprints(): from app.migrated_routes.category import bp as category_bp from app.migrated_routes.subcategory import bp as subcategory_bp + from app.migrated_routes.product import bp as product_bp api.register_blueprint(category_bp, url_prefix="/categories") api.register_blueprint(subcategory_bp, url_prefix="/subcategories") + api.register_blueprint(product_bp, url_prefix="/products") app = Flask(__name__) diff --git a/app/migrated_routes/product.py b/app/migrated_routes/product.py new file mode 100644 index 0000000..b0f3614 --- /dev/null +++ b/app/migrated_routes/product.py @@ -0,0 +1,311 @@ +from flask.views import MethodView +from flask_jwt_extended import jwt_required +from flask_smorest import Blueprint, abort +from psycopg2.errors import UniqueViolation +from sqlalchemy import UniqueConstraint +from sqlalchemy.exc import IntegrityError + +from app import db +from app.models import ( + Product, + Subcategory, + subcategory_product, +) +from app.schemas import ( + NameArgs, + PaginationArgs, + ProductIn, + ProductOut, + ProductsOut, + SubcategoriesOut, +) + +bp = Blueprint("product", __name__) + + +@bp.route("") +class ProductCollection(MethodView): + init_every_request = False + _PER_PAGE = 10 + + @staticmethod + def _get_name_unique_constraint(): + name_col = Product.__table__.c.name + return next( + con + for con in Product.__table__.constraints + if isinstance(con, UniqueConstraint) + and len(con.columns) == 1 + and con.columns.contains_column(name_col) + ) + + _NAME_UNIQUE_CONSTRAINT = _get_name_unique_constraint() + + def _get_by_name(self, name): + return Product.query.filter(Product.name == name) + + @bp.arguments(NameArgs, location="query", as_kwargs=True) + @bp.arguments(PaginationArgs, location="query", as_kwargs=True) + @bp.response(200, ProductsOut) + def get(self, name, page): + """ + Get All Products + --- + tags: + - Product + description: Get all products. + parameters: + - in: query + name: page + type: integer + default: 1 + description: Page number + - in: query + name: name + type: string + description: Name + responses: + 200: + description: Product by name or a paginated list of all products. + """ + if name is not None: + products = self._get_by_name(name) + else: + products = Product.query.order_by(Product.id.asc()).paginate( + page=page, per_page=ProductCollection._PER_PAGE, error_out=False + ) + + return {"products": products} + + @jwt_required() + @bp.arguments(ProductIn) + @bp.response(201, ProductOut) + def post(self, data): + """ + Create Product + --- + tags: + - Product + description: Create a new product. + security: + - access_token: [] + requestBody: + required: true + description: name - Name of the product
description - Description of the product (optional)
subcategories - Array of subcategory ids (optional) + content: + application/json: + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + subcategories: + type: array + items: + type: integer + responses: + 201: + description: Product created successfully. + 400: + description: Invalid input. + 401: + description: Token expired, missing or invalid. + 500: + description: Error occurred. + """ + product = Product(name=data["name"], description=data.get("description")) + + if sc_ids := data.get("subcategories"): + subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)).all() + if len(subcategories) != len(sc_ids): + abort(422, message="One or more subcategories not present") + product.subcategories = subcategories + + try: + db.session.add(product) + db.session.commit() + except IntegrityError as ie: + db.session.rollback() + if ( + isinstance(ie.orig, UniqueViolation) + and ie.orig.diag.constraint_name + == ProductCollection._NAME_UNIQUE_CONSTRAINT.name + ): + abort(409, message="Product with this name already exists") + raise + + return product + + +@bp.route("/") +class ProductById(MethodView): + init_every_request = False + + def _get(self, id): + return Product.query.get_or_404(id) + + @bp.response(200, ProductOut) + def get(self, id): + """ + Get Product + --- + tags: + - Product + description: Get a product by ID. + parameters: + - in: path + name: id + required: true + type: integer + description: Product ID + responses: + 200: + description: Product retrieved successfully. + 404: + description: Product not found. + """ + return self._get(id) + + @jwt_required() + @bp.arguments(ProductIn(partial=("name",))) + @bp.response(200, ProductOut) + def put(self, data, id): + """ + Update Product + --- + tags: + - Product + description: Update an existing product. + security: + - access_token: [] + consumes: + - application/json + parameters: + - in: path + name: id + required: true + type: integer + description: Product ID + requestBody: + required: true + description: name - Name of the product (optional)
description = Description of the product (optional)
subcategories - Array of subcategory ids (optional) + content: + application/json: + schema: + type: object + properties: + name: + type: string + description: + type: string + subcategories: + type: array + items: + type: integer + responses: + 201: + description: Product updated successfully. + 400: + description: Invalid input. + 404: + description: Product not found. + 500: + description: Error occurred. + """ + product = self._get(id) + + if name := data.get("name"): + product.name = name + if "description" in data: + product.description = data["description"] + + with db.session.no_autoflush: + if sc_ids := data.get("subcategories"): + subcategories = Subcategory.query.filter( + Subcategory.id.in_(sc_ids) + ).all() + if len(subcategories) != len(sc_ids): + abort(422, message="One or more subcategories not present") + product.subcategories.extend(subcategories) + + try: + db.session.commit() + except IntegrityError as ie: + db.session.rollback() + if ( + isinstance(ie.orig, UniqueViolation) + and ie.orig.diag.constraint_name + == ProductCollection._NAME_UNIQUE_CONSTRAINT.name + ): + abort(409, message="Product with this name already exists") + if ( + isinstance(ie.orig, UniqueViolation) + and ie.orig.diag.constraint_name == subcategory_product.primary_key.name + ): + abort(409, message="Product and subcategory already linked") + raise + + return product + + @jwt_required() + @bp.response(204) + def delete(self, id): + """ + Delete Product + --- + tags: + - Product + description: Delete a product by ID. + security: + - access_token: [] + parameters: + - in: path + name: id + required: true + type: integer + description: Product ID + responses: + 200: + description: Product deleted successfully. + 404: + description: Product not found. + 500: + description: Error occurred. + """ + product = self._get(id) + db.session.delete(product) + db.session.commit() + + +@bp.route("//subcategories") +class ProductSubcategories(MethodView): + init_every_request = False + + @bp.response(200, SubcategoriesOut) + def get(self, id): + """ + Get Subcategories related to a Product. + --- + tags: + - Product + description: Get Subcategories related to a Product. + parameters: + - in: path + name: id + required: true + type: integer + description: Product ID + responses: + 200: + description: Subcategories retrieved successfully. + 404: + description: Product not found. + 500: + description: Error occurred. + """ + product = Product.query.get_or_404(id) + return {"subcategories": product.subcategories} diff --git a/app/routes.py b/app/routes.py index 3046135..6519bf0 100644 --- a/app/routes.py +++ b/app/routes.py @@ -4,7 +4,7 @@ from email_validator import EmailNotValidError from app import app, db -from app.models import Subcategory, Product, User +from app.models import User @app.route('/auth/register', methods=['POST']) @@ -133,278 +133,3 @@ def refresh(): identity = get_jwt_identity() access_token = create_access_token(identity=identity, fresh=False) return jsonify(access_token=access_token), 200 - - -@app.route('/product/create', methods=['POST']) -@jwt_required() -def create_product(): - """ - Create Product - --- - tags: - - Product - description: Create a new product. - security: - - access_token: [] - requestBody: - required: true - description: name - Name of the product
description - Description of the product (optional)
subcategories - Array of subcategory ids (optional) - content: - application/json: - schema: - type: object - required: - - name - properties: - name: - type: string - description: - type: string - subcategories: - type: array - items: - type: integer - responses: - 201: - description: Product created successfully. - 400: - description: Invalid input. - 500: - description: Error occurred. - """ - if not request.json: - abort(400) - - try: - product = Product( - name=request.json.get('name'), - description=request.json.get('description') - ) - sc_ids = request.json.get('subcategories') - if sc_ids is not None: - subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)) - product.subcategories.extend(subcategories) - db.session.add(product) - db.session.commit() - return jsonify(product.to_json()), 201 - except: - return "Error occured", 500 - - -@app.route('/product/', methods=['GET']) -def get_product(p_id): - """ - Get Product - --- - tags: - - Product - description: Get a product by ID. - parameters: - - in: path - name: p_id - required: true - type: integer - description: Product ID - responses: - 200: - description: Product retrieved successfully. - 404: - description: Product not found. - """ - product = Product.query.get(p_id) - if product is None: - abort(404) - return jsonify(product.to_json()), 200 - - -@app.route('/product//update', methods=['PUT']) -@jwt_required() -def update_product(p_id): - """ - Update Product - --- - tags: - - Product - description: Update an existing product. - security: - - access_token: [] - consumes: - - application/json - parameters: - - in: path - name: p_id - required: true - type: integer - description: Product ID - requestBody: - required: true - description: name - Name of the product (optional)
description = Description of the product (optional)
subcategories - Array of subcategory ids (optional) - content: - application/json: - schema: - type: object - properties: - name: - type: string - description: - type: string - subcategories: - type: array - items: - type: integer - responses: - 201: - description: Product updated successfully. - 400: - description: Invalid input. - 404: - description: Product not found. - 500: - description: Error occurred. - """ - if not request.json: - abort(400) - - product = Product.query.get(p_id) - if product is None: - abort(404) - try: - name = request.json.get('name') - description = request.json.get('description') - sc_ids = request.json.get('subcategories') - if name is not None: - product.name = name - if description is not None: - product.description = description - if sc_ids is not None: - subcategories = Subcategory.query.filter(Subcategory.id.in_(sc_ids)) - product.subcategories.extend(subcategories) - db.session.commit() - return jsonify(product.to_json()), 201 - except: - return "Error occured", 500 - - -@app.route("/product/", methods=["DELETE"]) -@jwt_required() -def delete_product(p_id): - """ - Delete Product - --- - tags: - - Product - description: Delete a product by ID. - security: - - access_token: [] - parameters: - - in: path - name: p_id - required: true - type: integer - description: Product ID - responses: - 200: - description: Product deleted successfully. - 404: - description: Product not found. - 500: - description: Error occurred. - """ - product = Product.query.get(p_id) - if product is None: - abort(404) - try: - db.session.delete(product) - db.session.commit() - return jsonify({'result': True}), 200 - except: - return "Error occured", 500 - - -@app.route('/product', methods=['GET']) -def get_product_by_name(): - """ - Get Product by Name - --- - tags: - - Product - description: Get a product by name. - parameters: - - in: query - name: name - required: true - type: string - description: Product name - responses: - 200: - description: Product retrieved successfully. - 404: - description: Product not found. - 500: - description: Error occurred. - """ - name = request.args.get('name') - if not name: - abort(400, description="Missing required query parameter 'name'") - - product = Product.query.filter(Product.name == name).first() - if product is None: - abort(404) - return jsonify(product.to_json()), 200 - - -@app.route('/products', methods=['GET']) -def get_all_products(): - """ - Get All Products - --- - tags: - - Product - description: Get all products. - parameters: - - in: query - name: page - type: integer - default: 1 - description: Page number - responses: - 200: - description: A list of products for that page. - """ - page = request.args.get("page", default=1, type=int) - products = Product.query.order_by(Product.id.asc()).paginate(page=page, per_page=10, error_out=False) - return jsonify({"products": [product.to_json() for product in products]}), 200 - - -@app.route('/product//subcategories', methods=['GET']) -def get_product_subcategories(p_id): - """ - Get Subcategories related to a Product. - --- - tags: - - Product - description: Get Subcategories related to a Product. - parameters: - - in: path - name: p_id - required: true - type: integer - description: Product ID - responses: - 200: - description: Subcategories retrieved successfully. - 404: - description: Product not found. - 500: - description: Error occurred. - """ - product = Product.query.get(p_id) - if product is None: - abort(404) - - try: - return { - "subcategories": [sc.to_json() for sc in product.subcategories] - }, 200 - except: - return "Error occured", 500 diff --git a/app/schemas.py b/app/schemas.py index 0272c43..21d5b35 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -22,7 +22,7 @@ class Meta: @pre_load def strip_strings(self, data, **kwargs): - if "name" in data: + if "name" in data and data["name"] is not None: data["name"] = data["name"].strip() return data @@ -52,7 +52,7 @@ class Meta: @pre_load def strip_strings(self, data, **kwargs): - if "name" in data: + if "name" in data and data["name"] is not None: data["name"] = data["name"].strip() return data @@ -72,5 +72,32 @@ class ProductsOut(Schema): products = fields.List(fields.Nested(ProductOut)) +class ProductIn(SQLAlchemySchema): + class Meta: + model = Product + + name = auto_field() + description = auto_field() + subcategories = fields.List(fields.Int()) + + @pre_load + def strip_strings(self, data, **kwargs): + if "name" in data and data["name"] is not None: + data["name"] = data["name"].strip() + if "description" in data and data["description"] is not None: + data["description"] = data["description"].strip() + + return data + + @validates("name") + def validate_str_min_len(self, value, data_key): + if len(value) < 1: + raise ValidationError("Cannot be empty") + + +class NameArgs(Schema): + name = fields.Str(load_default=None) + + class PaginationArgs(Schema): page = fields.Int(load_default=1) diff --git a/tests/conftest.py b/tests/conftest.py index 757e634..ec74981 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -93,6 +93,6 @@ def _create(name, description=None, subcategories=None, headers=None): payload["description"] = description if subcategories is not None: payload["subcategories"] = subcategories - return client.post("/product/create", json=payload, headers=headers) + return client.post("/products", json=payload, headers=headers) return _create diff --git a/tests/test_product.py b/tests/test_product.py index 35d219f..9152af8 100644 --- a/tests/test_product.py +++ b/tests/test_product.py @@ -1,4 +1,7 @@ +import sqlite3 + import pytest +from sqlalchemy.exc import IntegrityError from app.models import Product from tests import utils @@ -40,9 +43,12 @@ def test_create_product(self, create_product): def test_create_product_duplicate_name(self, create_product): create_product(self.TEST_PRODUCT_NAME, self.TEST_PRODUCT_DESC) - response = create_product(self.TEST_PRODUCT_NAME, self.TEST_PRODUCT_DESC) - assert response.status_code == 500 + with pytest.raises(IntegrityError) as ie: + create_product(self.TEST_PRODUCT_NAME, self.TEST_PRODUCT_DESC) + + assert isinstance(ie.value.orig, sqlite3.IntegrityError) + assert "UNIQUE constraint failed" in str(ie.value.orig) assert self._count_products() == 1 self._verify_product_in_db(self.TEST_PRODUCT_NAME) @@ -50,7 +56,7 @@ def test_get_product_by_id(self, create_product): response = create_product("Pixel 6", "Google phone") data = response.get_json() p_id = data["id"] - get_resp = self.client.get(f"/product/{p_id}") + get_resp = self.client.get(f"/products/{p_id}") assert get_resp.status_code == 200 data = get_resp.get_json() @@ -76,12 +82,12 @@ def test_update_product(self, create_authenticated_headers, create_product): p_id = data["id"] update_resp = self.client.put( - f"/product/{p_id}/update", + f"/products/{p_id}", json={"name": "NewProduct", "description": "NewDesc"}, headers=create_authenticated_headers(), ) - assert update_resp.status_code == 201 + assert update_resp.status_code == 200 data = update_resp.get_json() assert data["name"] == "NewProduct" assert data["description"] == "NewDesc" @@ -89,17 +95,37 @@ def test_update_product(self, create_authenticated_headers, create_product): self._verify_product_in_db("NewProduct") self._verify_product_in_db("OldProduct", should_exist=False) + def test_update_product_duplicate_name( + self, create_authenticated_headers, create_product + ): + create_product("OldProduct", "OldDesc") + response = create_product("NewProduct", "NewDesc") + data = response.get_json() + cat_id = data["id"] + + with pytest.raises(IntegrityError) as ie: + self.client.put( + f"/products/{cat_id}", + json={"name": "OldProduct"}, + headers=create_authenticated_headers(), + ) + + assert isinstance(ie.value.orig, sqlite3.IntegrityError) + assert "UNIQUE constraint failed" in str(ie.value.orig) + self._verify_product_in_db("OldProduct") + self._verify_product_in_db("NewProduct") + def test_delete_product(self, create_authenticated_headers, create_product): response = create_product("ToDelete", "desc") data = response.get_json() p_id = data["id"] delete_resp = self.client.delete( - f"/product/{p_id}", headers=create_authenticated_headers() + f"/products/{p_id}", headers=create_authenticated_headers() ) - assert delete_resp.status_code == 200 - get_resp = self.client.get(f"/product/{p_id}") + assert delete_resp.status_code == 204 + get_resp = self.client.get(f"/products/{p_id}") assert get_resp.status_code == 404 self._verify_product_in_db("ToDelete", should_exist=False) @@ -118,19 +144,15 @@ def test_get_product_by_name(self, create_product, name): data = response.get_json() p_id = data["id"] - get_resp = self.client.get("/product", query_string={"name": name}) + get_resp = self.client.get("/products", query_string={"name": name}) assert get_resp.status_code == 200 - prod_data = get_resp.get_json() + prod_data = get_resp.get_json()["products"][0] assert prod_data["id"] == p_id assert prod_data["name"] == name assert prod_data["description"] == "desc" - not_found_resp = self.client.get("/product", query_string={"name": "Non existent product"}) - assert not_found_resp.status_code == 404 - - def test_get_product_by_name_missing_param_returns_400(self): - resp = self.client.get("/product") # no query param - assert resp.status_code == 400 + not_found_resp = self.client.get("/products", query_string={"name": "Non existent product"}) + assert not_found_resp.get_json()["products"] == [] @pytest.mark.parametrize( "get_headers, expected_code", @@ -143,7 +165,7 @@ def test_get_product_by_name_missing_param_returns_400(self): def test_create_product_token_error(self, get_headers, expected_code): headers = get_headers(self) response = self.client.post( - "/product/create", json={"name": "CreateTokenError"}, headers=headers + "/products", json={"name": "CreateTokenError"}, headers=headers ) utils.verify_token_error_response(response, expected_code) self._verify_product_in_db("CreateTokenError", should_exist=False) @@ -163,7 +185,7 @@ def test_update_product_token_error(self, get_headers, create_product, expected_ update_headers = get_headers(self) update_resp = self.client.put( - f"/product/{p_id}/update", + f"/products/{p_id}", json={"name": "UpdatedName"}, headers=update_headers, ) @@ -186,7 +208,7 @@ def test_delete_product_token_error(self, get_headers, create_product, expected_ p_id = data["id"] delete_headers = get_headers(self) - delete_resp = self.client.delete(f"/product/{p_id}", headers=delete_headers) + delete_resp = self.client.delete(f"/products/{p_id}", headers=delete_headers) utils.verify_token_error_response(delete_resp, expected_code) self._verify_product_in_db("DeleteTokenError") @@ -195,8 +217,8 @@ def test_products_pagination(self, create_product): for i in range(15): create_product(f"Product{i}", f"Description{i}") - # Page 1 - resp1 = self.client.get("/products?page=1") + # Page 1 - default + resp1 = self.client.get("/products") assert resp1.status_code == 200 data1 = resp1.get_json() assert "products" in data1 diff --git a/tests/test_relationships.py b/tests/test_relationships.py index 927d990..4190845 100644 --- a/tests/test_relationships.py +++ b/tests/test_relationships.py @@ -170,11 +170,33 @@ def test_update_product_adds_subcategories(self, create_authenticated_headers, c product = create_product("UP", "desc", subcategories=[subcategory1["id"]]).get_json() headers = create_authenticated_headers() - update_response = self.client.put(f"/product/{product['id']}/update", json={"subcategories": [subcategory2["id"]]}, headers=headers) - assert update_response.status_code == 201 + update_response = self.client.put(f"/products/{product['id']}", json={"subcategories": [subcategory2["id"]]}, headers=headers) + assert update_response.status_code == 200 assert self._product_subcategory_ids(product["id"]) == sorted([subcategory1["id"], subcategory2["id"]]) + def test_update_product_adds_linked_subcategories( + self, create_authenticated_headers, create_product, create_subcategory + ): + subcategory1 = create_subcategory("UPS1").get_json() + subcategory2 = create_subcategory("UPS2").get_json() + product = create_product( + "UP", "desc", subcategories=[subcategory1["id"], subcategory2["id"]] + ).get_json() + + with pytest.raises(IntegrityError) as ie: + self.client.put( + f"/products/{product['id']}", + json={"subcategories": [subcategory1["id"]]}, + headers=create_authenticated_headers(), + ) + + assert isinstance(ie.value.orig, sqlite3.IntegrityError) + assert "UNIQUE constraint failed" in str(ie.value.orig) + assert self._product_subcategory_ids(product["id"]) == sorted( + [subcategory1["id"], subcategory2["id"]] + ) + def test_get_category_subcategories_empty(self, create_category): category = create_category("Cat_NoSC").get_json() resp = self.client.get(f"/categories/{category['id']}/subcategories") @@ -246,7 +268,7 @@ def test_get_subcategory_products_populated_with_pagination(self, create_subcate def test_get_product_subcategories_empty(self, create_product): product = create_product("Prod_NoSC", "desc").get_json() - resp = self.client.get(f"/product/{product['id']}/subcategories") + resp = self.client.get(f"/products/{product['id']}/subcategories") self._assert_related_collection(resp, "subcategories") def test_get_product_subcategories_populated(self, create_product, create_subcategory): @@ -254,7 +276,7 @@ def test_get_product_subcategories_populated(self, create_product, create_subcat subcategory2 = create_subcategory("S2").get_json() product = create_product("Prod_SC", "desc", subcategories=[subcategory1["id"], subcategory2["id"]]).get_json() - resp = self.client.get(f"/product/{product['id']}/subcategories") + resp = self.client.get(f"/products/{product['id']}/subcategories") self._assert_related_collection(resp, "subcategories", expected_ids=[subcategory1["id"], subcategory2["id"]]) @pytest.mark.parametrize( @@ -264,7 +286,7 @@ def test_get_product_subcategories_populated(self, create_product, create_subcat "/categories/999999/products", "/subcategories/999999/categories", "/subcategories/999999/products", - "/product/999999/subcategories", + "/products/999999/subcategories", ], ) def test_relationship_getters_404(self, path):