diff --git a/.gitignore b/.gitignore index 894a44c..a050e84 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,7 @@ local_settings.py db.sqlite3 # Flask stuff: -instance/ +#instance/ .webassets-cache # Scrapy stuff: @@ -102,3 +102,6 @@ venv.bak/ # mypy .mypy_cache/ + +# VS code settings +.vscode/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c6d5171 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,31 @@ +env: + global: + - CC_TEST_REPORTER_ID=18007e474cdfcf70da58a682805aff9f88216339b0dce34454dc0bfcc988ea91 + - APP_SETTINGS="development" + - SECRET_KEY="mvangiffj38kncliu3yt7gvLWDNTDISFJWrk'\flQHJsdnlQI2ROH" + +language: python + +# python version +python: + - "3.6" + +# command to install dependencies +install: + - pip install -r requirements.txt + +before_script: + - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter + - chmod +x ./cc-test-reporter + - ./cc-test-reporter before-build + +# command to run tests +script: + - coverage run --source=app.api.v1 -m pytest app/tests/v1 -v -W error::UserWarning && coverage report + +after_script: + - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT + +# Post coverage results to coverage.io +after_success: + - coveralls \ No newline at end of file diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..80a980e --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn run:app \ No newline at end of file diff --git a/README.md b/README.md index 41ee15b..6541db5 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,86 @@ # store-manager-api + +#### Continous Integration badges +[![Build Status](https://travis-ci.com/calebrotich10/store-manager-api.svg?branch=develop)](https://travis-ci.com/calebrotich10/store-manager-api) [![Coverage Status](https://coveralls.io/repos/github/calebrotich10/store-manager-api/badge.svg?branch=develop)](https://coveralls.io/github/calebrotich10/store-manager-api?branch=develop) [![Maintainability](https://api.codeclimate.com/v1/badges/e87820f417b8d15c3a64/maintainability)](https://codeclimate.com/github/calebrotich10/store-manager-api/maintainability) + + Store Manager is a web application that helps store owners manage sales and product inventory records. This application is meant for use in a single store. This repository contains the API endpoints for the application. + +#### Endpoints + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Http MethodEndpointFunctionality
POSTapi/v1/auth/signupCreates a new user account
POSTapi/v1/productsUsed by the admin to add a new product
POSTapi/v1/saleorderUsed by the sale attendant to add a new sale order
GETapi/v1/auth/signinAuthenticates and creates a token for the users
GETapi/v1/products/<product_id>Enables a user to fetch a specific product
GETapi/v1/saleorder/<sale_order_id>Enables a user to fetch a specific sale order
GETapi/v1/productsEnables a user to fetch all products
GETapi/v1/saleorderEnables a user to fetch all sale orders
+ +#### Installing the application +1. Open a command terminal in your preferred folder +2. Run command `git clone https://github.com/calebrotich10/store-manager-api.git` to have a copy locally +3. `cd store-manager-api` +4. Create a virtual environment for the application `virtualenv venv` +5. Install dependencies from the `requirements.txt` file `pip3 install -r requirements.txt` +6. Export environment variables to your environment ```export JWT_SECRET_KEY=your-secret-key```, ```export FLASK_APP="run.py"``` +6. Run the application using flask command `flask run` or using python3 `python3 run.py` + +#### Running tests +Inside the virtual environment created above, run command: `coverage run --source=app.api.v1 -m pytest app/tests/v1 -v -W error::UserWarning && coverage report` + +#### Technologies used +1. `JWT` for authentication +2. `pytest` for running tests +3. Python based framework `flask` +4. Flask packages + +#### Deployment +[Heroku](https://store-manager-api.herokuapp.com/api/v1/products) + +#### Documentation +https://documenter.getpostman.com/view/5265531/RWguxwzy + +#### Author +[Caleb Rotich](https://github.com/calebrotich10) + +#### Credits +This application was build as part of the Andela NBO 33 challenge diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..acbc658 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,23 @@ +import os + +from flask import Flask +from flask_jwt_extended import JWTManager + +from instance.config import config + +jwt = JWTManager() + +def create_app(config_name): + app = Flask(__name__) + app.config.from_object(config[config_name]) + app.config['JWT_SECRET_KEY'] = os.getenv('JWT_SECRET_KEY', default='SdaHv342nx!jknr837bjwd?c,lsajjjhw673hdsbgeh') + + jwt.init_app(app) + + from .api.v1 import endpoint_v1_blueprint as v1_blueprint + app.register_blueprint(v1_blueprint, url_prefix='/api/v1') + + from .api.v1 import auth_v1_blueprint as v1_blueprint + app.register_blueprint(v1_blueprint, url_prefix='/api/v1/auth') + + return app diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..e6978c0 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1,9 @@ +from flask import Blueprint + + +endpoint_v1_blueprint = Blueprint('endpoint_v1_blueprint', __name__) +auth_v1_blueprint = Blueprint('auth_v1_blueprint', __name__) + + + +from . import views \ No newline at end of file diff --git a/app/api/v1/models/products.py b/app/api/v1/models/products.py new file mode 100644 index 0000000..9eb36d2 --- /dev/null +++ b/app/api/v1/models/products.py @@ -0,0 +1,24 @@ +"""This module contains the data store + +and data logic of the store's products +""" + +PRODUCTS = [] + +class Products(): + def __init__(self, product_name, product_price, category): + self.id = len(PRODUCTS) + 1 + self.product_name = product_name + self.product_price = product_price + self.category = category + + def save(self): + new_product = { + "product_id": self.id, + "product_name": self.product_name, + "product_price": self.product_price, + "category": self.category + } + + PRODUCTS.append(new_product) + return new_product \ No newline at end of file diff --git a/app/api/v1/models/sale_orders.py b/app/api/v1/models/sale_orders.py new file mode 100644 index 0000000..ca259b0 --- /dev/null +++ b/app/api/v1/models/sale_orders.py @@ -0,0 +1,26 @@ +"""This module contains the data store + +and data logic of the store attendant's sale orders +""" +from flask import jsonify + +SALE_ORDERS = [] + +class SaleOrder(): + def __init__(self, product_name, product_price, quantity): + self.id = len(SALE_ORDERS) + 1 + self.product_name = product_name + self.product_price = product_price + self.quantity = quantity + + def save(self): + new_sale_order = { + "sale_order_id": self.id, + "product_name": self.product_name, + "product_price": self.product_price, + "quantity": self.quantity, + "amount": (self.product_price * self.quantity) + } + + SALE_ORDERS.append(new_sale_order) + return new_sale_order \ No newline at end of file diff --git a/app/api/v1/models/users.py b/app/api/v1/models/users.py new file mode 100644 index 0000000..138d06d --- /dev/null +++ b/app/api/v1/models/users.py @@ -0,0 +1,27 @@ +from flask import make_response, jsonify +USERS = [] + + +class User_Model(): + def __init__(self, email, password, role): + self.id = len(USERS) + 1 + self.email = email + self.password = password + self.role = role + + def save(self): + new_user = { + "id": self.id, + "email": self.email, + "password": self.password, + "role": self.role + } + + new_added_user = { + "id": self.id, + "email": self.email, + "role": self.role + } + + USERS.append(new_user) + return new_added_user \ No newline at end of file diff --git a/app/api/v1/resources/__init__.py b/app/api/v1/resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/v1/resources/admin_endpoints.py b/app/api/v1/resources/admin_endpoints.py new file mode 100644 index 0000000..94441db --- /dev/null +++ b/app/api/v1/resources/admin_endpoints.py @@ -0,0 +1,70 @@ +"""This module contains endpoints + +that are specific to the admin +""" +import os +import jwt +from functools import wraps + +from flask import Flask, jsonify, request, abort, make_response +from flask_restful import Resource +from flask_jwt_extended import get_jwt_identity, jwt_required + +from . import helper_functions +from app.api.v1.models import products, sale_orders, users +from . import verify + + +class ProductsManagement(Resource): + """Class contains admin specific endpoints""" + + + def post(self): + """POST /products endpoint""" + + # Token verification and admin user determination + logged_user = verify.verify_tokens() + helper_functions.abort_if_user_is_not_admin(logged_user) + + data = request.get_json() + helper_functions.no_json_in_request(data) + try: + product_name = data['product_name'] + product_price = data['product_price'] + category = data['category'] + except KeyError: + # If product is missing required parameter + helper_functions.missing_a_required_parameter() + + verify.verify_post_product_fields(product_price, product_name, category) + + response = helper_functions.add_product_to_store(product_name, product_price, category) + + return make_response(jsonify({ + "message": "Product added successfully", + "product": response + }), 201) + + +class SaleAttendantsManagement(Resource): + """Class contains the tests managing + + sale orders + """ + + def get(self): + """GET /saleorder endpoint""" + + verify.verify_tokens() + + if not sale_orders.SALE_ORDERS: + # If no products exist in the store yet + abort(make_response( + jsonify(message="There are no sale orders made yet"), 404)) + # if at least one product exists + response = jsonify({ + 'message': "Successfully fetched all the sale orders", + 'sale_orders': sale_orders.SALE_ORDERS + }) + response.status_code = 200 + return response diff --git a/app/api/v1/resources/auth.py b/app/api/v1/resources/auth.py new file mode 100644 index 0000000..6d9bdce --- /dev/null +++ b/app/api/v1/resources/auth.py @@ -0,0 +1,60 @@ +import os +import datetime +import jwt +from werkzeug.security import generate_password_hash, check_password_hash +from functools import wraps + +from flask import Flask, jsonify, request, make_response +from flask_restful import Resource +from flask_jwt_extended import ( + jwt_required, create_access_token, + get_jwt_identity +) + +from instance import config +from app.api.v1.utils.validator import Validator +from app.api.v1.models import users + +class SignUp(Resource): + def post(self): + data = request.get_json() + if not data: + return make_response(jsonify({ + "message": "Missing required credentials" + }), 400) + email = data["email"].strip() + password = generate_password_hash(data["password"].strip(), method='sha256') + role = data["role"].strip() + Validator.validate_credentials(self, data) + user = users.User_Model(email, password, role) + res = user.save() + return make_response(jsonify({ + "message": "Account created successfully", + "user": res + }), 202) + + +class Login(Resource): + def post(self): + data = request.get_json() + if not data: + return make_response(jsonify({ + "message": "Kindly enter your credentials" + } + ), 400) + email = data["email"].strip() + password = data["password"].strip() + + for user in users.USERS: + if email == user["email"] and check_password_hash(user["password"], password): + token = jwt.encode({ + "email": email, + "exp": datetime.datetime.utcnow() + datetime.timedelta(minutes=30) + }, os.getenv('JWT_SECRET_KEY', default='SdaHv342nx!jknr837bjwd?c,lsajjjhw673hdsbgeh')) + return make_response(jsonify({ + "message": "Login successful", + "token": token.decode("UTF-8")}), 200) + return make_response(jsonify({ + "message": "Wrong credentials provided" + } + ), 403) diff --git a/app/api/v1/resources/general_users_endpoints.py b/app/api/v1/resources/general_users_endpoints.py new file mode 100644 index 0000000..fe212ee --- /dev/null +++ b/app/api/v1/resources/general_users_endpoints.py @@ -0,0 +1,73 @@ +"""This module contains endpoints + +for both the admin and the normal user +""" +from flask import jsonify, abort, make_response +from flask_restful import Resource +from flask_jwt_extended import get_jwt_identity, jwt_required +from . import verify + +from . import helper_functions +from app.api.v1.models import products, sale_orders + +class AllProducts(Resource): + """Class contains the tests for both + + the admin and the normal user endpoints + """ + + def get(self): + """GET /products endpoint""" + verify.verify_tokens() + if not products.PRODUCTS: + # If no products exist in the store yet + abort(make_response( + jsonify(message="There are no products in the store yet"), 404)) + # if at least one product exists + response = jsonify({ + 'message': "Successfully fetched all the products", + 'products': products.PRODUCTS + }) + response.status_code = 200 + return response + +class SpecificProduct(Resource): + + def get(self, product_id): + + verify.verify_tokens() + + for product in products.PRODUCTS: + if product["product_id"] == product_id: + return make_response(jsonify({ + "message": "{} retrieved successfully".format(product["product_name"]), + "product": product + } + ), 200) + + else: + return make_response(jsonify({ + "message": "Product with id {} not found".format(product_id) + } + ), 404) + + +class SpecificSaleOrder(Resource): + + def get(self, sale_order_id): + """GET /saleorder/""" + + verify.verify_tokens() + + for sale_order in sale_orders.SALE_ORDERS: + if sale_order["sale_order_id"] == sale_order_id: + return make_response(jsonify({ + "message": "Sale Order with Id {} retrieved successfully".format(sale_order["sale_order_id"]), + "product": sale_order + } + ), 200) + + return make_response(jsonify({ + "message": "Sale Order with id {} not found".format(sale_order_id) + } + ), 404) \ No newline at end of file diff --git a/app/api/v1/resources/helper_functions.py b/app/api/v1/resources/helper_functions.py new file mode 100644 index 0000000..a0aeac7 --- /dev/null +++ b/app/api/v1/resources/helper_functions.py @@ -0,0 +1,57 @@ +from datetime import datetime + +from flask import abort, jsonify, make_response + +from app.api.v1.models import products, sale_orders, users + +def no_json_in_request(data): + """Aborts if the data does + + not contain a json object + """ + + if data is None: + # If a json was not obtained from the request + abort(make_response(jsonify( + message="Bad request. Request data must be in json format"), 400)) + + +def missing_a_required_parameter(): + """Aborts if request data is missing a + + required argument + """ + abort(make_response(jsonify( + message="Bad request. Request missing a required argument"), 400)) + + +def abort_if_user_is_not_admin(user): + user_role = [users['role'] for users in users.USERS if users['email'] == user][0] + if user_role!= "admin": + abort(make_response(jsonify( + message="Unauthorized. This action is not for you" + ), 401)) + + +def add_product_to_store(product_name, product_price, category): + if products.PRODUCTS: + # If products are in the store already + try: + # Check if a product with a similar name exists + existing_product = [ + product for product in products.PRODUCTS if product['product_name'] == product_name][0] + + abort(make_response(jsonify({ + "message": "Product with a similar name already exists", + "product": existing_product}), 400)) + + except IndexError: + # If there is no product with the same name + added_product = products.Products(product_name, product_price, category) + response = added_product.save() + else: + # If there are no products in the store + added_product = products.Products(product_name, product_price, category) + response = added_product.save() + + return response \ No newline at end of file diff --git a/app/api/v1/resources/store_attendant_endpoints.py b/app/api/v1/resources/store_attendant_endpoints.py new file mode 100644 index 0000000..ae2be35 --- /dev/null +++ b/app/api/v1/resources/store_attendant_endpoints.py @@ -0,0 +1,63 @@ +"""This module contains endpoints + +that are specific to the store attendant +""" +from flask import Flask, jsonify, request, abort, make_response +from flask_restful import Resource +from flask_jwt_extended import get_jwt_identity, jwt_required + +from . import helper_functions +from app.api.v1.models import products, sale_orders +from . import verify + +class SaleRecords(Resource): + """Class contains the tests for store attendant + + specific endpoints + """ + + def post(self): + """POST /saleorder endpoint""" + + verify.verify_tokens() + + data = request.get_json() + helper_functions.no_json_in_request(data) + try: + product_name = data['product_name'] + product_price = data['product_price'] + quantity = data['quantity'] + except KeyError: + # If product is missing required parameter + helper_functions.missing_a_required_parameter() + + if not isinstance(product_price, int): + abort(make_response(jsonify( + message="Bad request. The product price should be digits" + ), 400)) + + if product_price < 1: + abort(make_response(jsonify( + message="Bad request. Price of the product should be a positive integer above 0." + ), 400)) + + if not isinstance(product_name, str): + abort(make_response(jsonify( + message="Bad request. Product name should be a string" + ), 400)) + + + if not isinstance(quantity, int): + abort(make_response(jsonify( + message="Bad request. The quantity should be specified in digits" + ), 400)) + + # response = helper_functions.add_new_sale_record(product_name, product_price, quantity, amount) + sale_order = sale_orders.SaleOrder(product_name, product_price, quantity) + response = sale_order.save() + + + return make_response(jsonify({ + "message": "Checkout complete", + "saleorder": response + }), 201) diff --git a/app/api/v1/resources/verify.py b/app/api/v1/resources/verify.py new file mode 100644 index 0000000..691e418 --- /dev/null +++ b/app/api/v1/resources/verify.py @@ -0,0 +1,43 @@ +import os +import jwt +from functools import wraps + +from flask import request, make_response, jsonify, abort +from ..models import users + + +def verify_tokens(): + token = None + if 'Authorization' in request.headers: + token = request.headers['Authorization'] + if not token: + abort(make_response(jsonify({ + "Message": "You need to login"}), 401)) + try: + data = jwt.decode(token, os.getenv('JWT_SECRET_KEY', default='SdaHv342nx!jknr837bjwd?c,lsajjjhw673hdsbgeh')) + for user in users.USERS: + if user['email'] == data['email']: + return user["email"] + + except: + abort(make_response(jsonify({ + "Message": "The token is either expired or wrong" + }), 403)) + + + +def verify_post_product_fields(product_price, product_name, category): + if product_price < 1: + abort(make_response(jsonify( + message="Bad request. Price of the product should be a positive integer above 0." + ), 400)) + + if not isinstance(product_name, str): + abort(make_response(jsonify( + message="Bad request. Product name should be a string" + ), 400)) + + if not isinstance(category, str): + abort(make_response(jsonify( + message="Bad request. The Category should be a string" + ), 400)) \ No newline at end of file diff --git a/app/api/v1/utils/validator.py b/app/api/v1/utils/validator.py new file mode 100644 index 0000000..af8acaf --- /dev/null +++ b/app/api/v1/utils/validator.py @@ -0,0 +1,39 @@ +import re + +from flask import make_response, jsonify, abort +from validate_email import validate_email + +from app.api.v1.models import users + + +class Validator: + def validate_credentials(self, data): + self.email = data["email"].strip() + self.password = data["password"].strip() + self.role = data["role"].strip() + valid_email = validate_email(self.email) + for user in users.USERS: + if self.email == user["email"]: + Message = "User already exists" + abort(406, Message) + if self.email == "" or self.password == "" or self.role == "": + Message = "You are missing a required credential" + abort(400, Message) + if not valid_email: + Message = "Invalid email" + abort(400, Message) + elif len(self.password) < 6 or len(self.password) > 12: + Message = "Password must be long than 6 characters or less than 12" + abort(400, Message) + elif not any(char.isdigit() for char in self.password): + Message = "Password must have a digit" + abort(400, Message) + elif not any(char.isupper() for char in self.password): + Message = "Password must have an upper case character" + abort(400, Message) + elif not any(char.islower() for char in self.password): + Message = "Password must have a lower case character" + abort(400, Message) + elif not re.search("^.*(?=.*[@#$%^&+=]).*$", self.password): + Message = "Password must have a special charater" + abort(400, Message) diff --git a/app/api/v1/views.py b/app/api/v1/views.py new file mode 100644 index 0000000..4c71a85 --- /dev/null +++ b/app/api/v1/views.py @@ -0,0 +1,20 @@ +"""Define API endpoints as routes""" + +from flask_restful import Api, Resource + +from . import endpoint_v1_blueprint, auth_v1_blueprint +from .resources import admin_endpoints, general_users_endpoints, store_attendant_endpoints, auth + +API = Api(endpoint_v1_blueprint) +AUTH_API = Api(auth_v1_blueprint) + + +API.add_resource(admin_endpoints.ProductsManagement, '/products') +API.add_resource(general_users_endpoints.AllProducts, '/products') +API.add_resource(general_users_endpoints.SpecificProduct, '/products/') +API.add_resource(store_attendant_endpoints.SaleRecords, '/saleorder') +API.add_resource(admin_endpoints.SaleAttendantsManagement, '/saleorder') +API.add_resource(general_users_endpoints.SpecificSaleOrder, '/saleorder/') + +AUTH_API.add_resource(auth.SignUp, '/signup') +AUTH_API.add_resource(auth.Login, '/login') \ No newline at end of file diff --git a/app/tests/__init__.py b/app/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/v1/__init__.py b/app/tests/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/tests/v1/base_test.py b/app/tests/v1/base_test.py new file mode 100644 index 0000000..6a528d8 --- /dev/null +++ b/app/tests/v1/base_test.py @@ -0,0 +1,85 @@ +""" + Contains the base test class for the + other test classes +""" +import unittest + +# local imports +from app import create_app +from instance.config import config +from . import helper_functions + + +class TestBaseClass(unittest.TestCase): + """Base test class""" + + + def setUp(self): + """Create and setup the application + + for testing purposes + """ + self.app = create_app('testing') + self.BASE_URL = 'api/v1' + self.app_context = self.app.app_context() + self.app_context.push() + self.app_test_client = self.app.test_client() + self.app.testing = True + + self.PRODUCT = { + 'product_name': 'Phone Model 1', + 'product_price': 55000, + 'category': 'Phones' + } + + self.SALE_ORDERS = { + 'product_name': 'Phone Model 1', + 'product_price': 55000, + 'quantity': 6, + 'amount': (55000 * 6) + } + + + def tearDown(self): + """Destroy the application that + + is created for testing + """ + self.app_context.pop() + + def register_test_admin_account(self): + #Register attendant + """Registers an admin test user account""" + + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "user@gmail.com", + "role": "admin", + "password": "Password12#" + }, + headers={ + "Content-Type": "application/json" + }) + + return res + + def login_test_admin(self): + """Validates the test account for the admin""" + + # Login the test account for the admin + resp = self.app_test_client.post("api/v1/auth/login", + json={ + "email": "user@gmail.com", + "password": "Password12#" + }, + headers={ + "Content-Type": "application/json" + }) + + auth_token = helper_functions.convert_response_to_json( + resp)['token'] + + return auth_token + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/app/tests/v1/helper_functions.py b/app/tests/v1/helper_functions.py new file mode 100644 index 0000000..36e4717 --- /dev/null +++ b/app/tests/v1/helper_functions.py @@ -0,0 +1,7 @@ +import json + +def convert_response_to_json(response): + """Converts the response to a json type""" + + json_response = json.loads(response.data.decode('utf-8')) + return json_response \ No newline at end of file diff --git a/app/tests/v1/test_admin_endpoints.py b/app/tests/v1/test_admin_endpoints.py new file mode 100644 index 0000000..f0c8ad1 --- /dev/null +++ b/app/tests/v1/test_admin_endpoints.py @@ -0,0 +1,165 @@ +"""Module contains tests for admin + +specific endpoints +""" + +import json + +from flask import current_app + +from . import base_test +from . import helper_functions + +class TestAdminEndpoints(base_test.TestBaseClass): + """ Class contains tests for admin specific endpoints """ + + def test_add_new_product(self): + """Test POST /products""" + self.register_test_admin_account() + token = self.login_test_admin() + + # send a dummy data response for testing + response = self.app_test_client.post('{}/products'.format( + self.BASE_URL), json=self.PRODUCT, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 201) + self.assertEqual(helper_functions.convert_response_to_json( + response)['product']['product_name'], self.PRODUCT['product_name']) + self.assertEqual(helper_functions.convert_response_to_json( + response)['product']['product_id'], 1) + self.assertEqual(helper_functions.convert_response_to_json( + response)['product']['product_price'], 55000) + self.assertEqual(helper_functions.convert_response_to_json( + response)['product']['category'], self.PRODUCT['category']) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Product added successfully') + + def test_add_new_product_parameter_missing(self): + """Test POST /products + + with one of the required parameters missing + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/products'.format( + self.BASE_URL), json={'product_name': 'Nyundo'}, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Bad request. Request missing a required argument') + + def test_add_new_product_price_under_one(self): + """Test POST /products + + with the price of the product below minimum + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/products'.format( + self.BASE_URL), json={ + 'product_id': 1, 'product_name': "Hammer", 'product_price': 0, 'category':'Tools' + }, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], + 'Bad request. Price of the product should be a positive integer above 0.') + + + def test_add_new_product_with_product_name_not_string(self): + """Test POST /products + + with the product name not a string + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/products'.format( + self.BASE_URL), json={ + 'product_id': 1, 'product_name': 200, 'product_price': 200, 'category':'Tools' + }, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], + 'Bad request. Product name should be a string') + + def test_add_new_product_with_category_not_string(self): + """Test POST /products + + with the category not a string + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/products'.format( + self.BASE_URL), json={ + 'product_id': 1, 'product_name': "Hammer", 'product_price': 200, 'category': 200 + }, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], + 'Bad request. The Category should be a string') + + def test_add_new_product_with_product_name_already_existing(self): + """Test POST /products + + with the product name already existing + """ + self.register_test_admin_account() + token = self.login_test_admin() + + self.app_test_client.post('{}/products'.format( + self.BASE_URL), json={ + 'product_id': 1, 'product_name': "Hammer", 'product_price': 200, 'category': "Tools" + }, headers=dict(Authorization=token), + content_type='application/json') + + response = self.app_test_client.post('{}/products'.format( + self.BASE_URL), json={ + 'product_id': 1, 'product_name': "Hammer", 'product_price': 200, 'category': "Tools" + }, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], + 'Product with a similar name already exists') + + + def test_fetch_sale_orders(self): + """Test GET /saleorder - when sale order exists""" + self.register_test_admin_account() + token = self.login_test_admin() + + self.app_test_client.post( + '{}/saleorder'.format(self.BASE_URL), json={ + 'sale_order_id': 1, + 'product_name': "Test Product", + 'product_price': 20, + 'quantity': 1, + 'amount': 20 + }, + headers=dict(Authorization=token), + content_type='application/json') + + response = self.app_test_client.get( + '{}/saleorder'.format(self.BASE_URL), + + headers=dict(Authorization=token), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(helper_functions.convert_response_to_json( + response)['sale_orders'][0]['product_name'], "Test Product") + self.assertEqual(helper_functions.convert_response_to_json( + response)['sale_orders'][0]['product_price'], 20) diff --git a/app/tests/v1/test_app_working.py b/app/tests/v1/test_app_working.py new file mode 100644 index 0000000..4574c93 --- /dev/null +++ b/app/tests/v1/test_app_working.py @@ -0,0 +1,18 @@ +""" + Module contains tests for the working + of the application +""" +from flask import current_app + +#local imports +from . import base_test + +class TestConfigCase(base_test.TestBaseClass): + + + def test_app_exists(self): + self.assertFalse(current_app is None) + + + def test_app_is_testing(self): + self.assertTrue(current_app.config['TESTING']) \ No newline at end of file diff --git a/app/tests/v1/test_auth_endpoints.py b/app/tests/v1/test_auth_endpoints.py new file mode 100644 index 0000000..9f2c3af --- /dev/null +++ b/app/tests/v1/test_auth_endpoints.py @@ -0,0 +1,219 @@ +"""Module contains tests to endpoints that + +are used for user registration and authentication +""" + +import json + +from . import base_test +from . import helper_functions + +class TestAuthEndpoints(base_test.TestBaseClass): + """ Class contains tests for auth endpoints """ + + def test_add_new_user(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user@gmail.com", + "role": "Admin", + "password": "Password12#" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['user']['email'], "test_add_new_user@gmail.com") + self.assertEqual(res.status_code, 202) + + def test_add_new_user_no_data(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(res) + + self.assertEqual(data['message'], "Missing required credentials") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_missing_params(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "", + "role": "Admin", + "password": "Password12#" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "You are missing a required credential") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_invalid_email(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user", + "role": "Admin", + "password": "Password12#" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "Invalid email") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_no_digit_password(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user_invalid_email@gmail.com", + "role": "Admin", + "password": "No#digit" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "Password must have a digit") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_short_password(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user_invalid_email@gmail.com", + "role": "Admin", + "password": "Shor#" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "Password must be long than 6 characters or less than 12") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_no_special_ch_password(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user_invalid_email@gmail.com", + "role": "Admin", + "password": "NoSplCh12" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "Password must have a special charater") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_no_upper_case_password(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user_invalid_email@gmail.com", + "role": "Admin", + "password": "noupper12#" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "Password must have an upper case character") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_no_lower_case_password(self): + res = self.app_test_client.post("api/v1/auth/signup", + json={ + "email": "test_add_new_user_invalid_email@gmail.com", + "role": "Admin", + "password": "NOLOWER12#" + }, + headers={ + "Content-Type": "application/json" + }) + + data = json.loads(res.data.decode()) + print(data) + + self.assertEqual(data['message'], "Password must have a lower case character") + self.assertEqual(res.status_code, 400) + + def test_add_new_user_existing(self): + """Test POST /auth/signup""" + self.register_test_admin_account() + response = self.register_test_admin_account() + data = json.loads(response.data.decode()) + + self.assertEqual(data['message'], "User already exists") + self.assertEqual(response.status_code, 406) + + def test_login_existing_user(self): + self.register_test_admin_account() + resp = self.app_test_client.post("api/v1/auth/login", + json={ + "email": "user@gmail.com", + "password": "Password12#" + }, + headers={ + "Content-Type": "application/json" + }) + + self.assertTrue(helper_functions.convert_response_to_json( + resp)['token']) + self.assertTrue(helper_functions.convert_response_to_json( + resp)['message'], "You are successfully logged in!") + self.assertEqual(resp.status_code, 200) + + def test_login_no_credentials(self): + self.register_test_admin_account() + resp = self.app_test_client.post("api/v1/auth/login", + json={ + + }, + headers={ + "Content-Type": "application/json" + }) + + self.assertTrue(helper_functions.convert_response_to_json( + resp)['message'], "Kindly enter your credentials") + self.assertEqual(resp.status_code, 400) + + def test_login_non_matching_credentials(self): + self.register_test_admin_account() + resp = self.app_test_client.post("api/v1/auth/login", + json={ + "email": "non_matching_credentials_user_1018@gmail.com", + "password": "neverexpecteduser" + }, + headers={ + "Content-Type": "application/json" + }) + + self.assertTrue(helper_functions.convert_response_to_json( + resp)['message'], "Wrong credentials provided") + self.assertEqual(resp.status_code, 403) + \ No newline at end of file diff --git a/app/tests/v1/test_general_user_endpoints.py b/app/tests/v1/test_general_user_endpoints.py new file mode 100644 index 0000000..d2b2fd6 --- /dev/null +++ b/app/tests/v1/test_general_user_endpoints.py @@ -0,0 +1,107 @@ +"""Module contains tests to endpoints that + +are general to both the admin and the normal user +""" + +import json + +from . import base_test +from . import helper_functions + +class TestGeneralUsersEndpoints(base_test.TestBaseClass): + """Class contains the general user, i.e. both admin + + and normal user, endpoints' tests + """ + + + def test_retrieve_all_products(self): + """Test GET /products - when products exist""" + + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.get( + '{}/products'.format(self.BASE_URL), + headers=dict(Authorization=token), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(helper_functions.convert_response_to_json( + response)['products'][0]['product_name'], self.PRODUCT['product_name']) + + def test_retrieve_specific_product(self): + """Test GET /products/id - when product exist""" + + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.get( + '{}/products/1'.format(self.BASE_URL), + headers=dict(Authorization=token), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(helper_functions.convert_response_to_json( + response)['product']['product_name'], self.PRODUCT['product_name']) + + def test_retrieve_specific_sale_order(self): + """Test GET /saleorder/id - when saleorder exists""" + + self.register_test_admin_account() + token = self.login_test_admin() + + self.app_test_client.post( + '{}/saleorder'.format(self.BASE_URL), json={ + 'sale_order_id': 1, + 'product_name': "Test Product", + 'product_price': 20, + 'quantity': 1, + 'amount': 20 + }, + headers=dict(Authorization=token), + content_type='application/json') + + response = self.app_test_client.get( + '{}/saleorder/1'.format(self.BASE_URL), + headers=dict(Authorization=token), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + + + def test_missing_token(self): + """Test GET /products - when token is missing""" + + self.register_test_admin_account() + token = "" + + response = self.app_test_client.get( + '{}/products'.format(self.BASE_URL), + headers=dict(Authorization=token), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 401) + self.assertEqual(helper_functions.convert_response_to_json( + response)["Message"], "You need to login") + + + def test_invalid_token(self): + """Test GET /products - when token is missing""" + + self.register_test_admin_account() + token = "sample_invalid-token-afskdghkfhwkedaf-ksfakjfwey" + + response = self.app_test_client.get( + '{}/products'.format(self.BASE_URL), + headers=dict(Authorization=token), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(helper_functions.convert_response_to_json( + response)["Message"], "The token is either expired or wrong") \ No newline at end of file diff --git a/app/tests/v1/test_store_attendant_endpoints.py b/app/tests/v1/test_store_attendant_endpoints.py new file mode 100644 index 0000000..c78d9b1 --- /dev/null +++ b/app/tests/v1/test_store_attendant_endpoints.py @@ -0,0 +1,124 @@ +"""Module contains tests for store attendant + +specific endpoints +""" +from flask import current_app + +from . import base_test +from . import helper_functions + +class TestAdminEndpoints(base_test.TestBaseClass): + """ Class contains tests for store attendant specific endpoints """ + + + def test_create_sale_order(self): + """Test POST /saleorder""" + self.register_test_admin_account() + token = self.login_test_admin() + + # send a dummy data response for testing + response = self.app_test_client.post('{}/saleorder'.format( + self.BASE_URL), json=self.SALE_ORDERS, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 201) + self.assertEqual(helper_functions.convert_response_to_json( + response)['saleorder']['product_name'], self.SALE_ORDERS['product_name']) + self.assertEqual(helper_functions.convert_response_to_json( + response)['saleorder']['product_price'], self.SALE_ORDERS['product_price']) + self.assertEqual(helper_functions.convert_response_to_json( + response)['saleorder']['quantity'], self.SALE_ORDERS['quantity']) + self.assertEqual(helper_functions.convert_response_to_json( + response)['saleorder']['amount'], self.SALE_ORDERS['amount']) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Checkout complete') + + + def test_create_sale_order_parameter_missing(self): + """Test POST /saleorder + + with one of the required parameters missing + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/saleorder'.format( + self.BASE_URL), json={'product_name': 'Nyundo'}, headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Bad request. Request missing a required argument') + + + def test_create_sale_order_price_below_one(self): + """Test POST /saleorder + + with the price not a valid integer + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/saleorder'.format( + self.BASE_URL), json={'product_name': 'Nyundo', 'product_price': -1, 'quantity': 1}, + headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Bad request. Price of the product should be a positive integer above 0.') + + + def test_create_sale_order_invalid_product_name(self): + """Test POST /saleorder + + with the product name not a string + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/saleorder'.format( + self.BASE_URL), json={'product_name': 3, 'product_price': 300, 'quantity': 1}, + headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Bad request. Product name should be a string') + + + def test_create_sale_order_price_not_digits(self): + """Test POST /saleorder + + with the price not a valid integer + """ + + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/saleorder'.format( + self.BASE_URL), json={'product_name': "Nyundo", 'product_price': "300", 'quantity': 1}, + headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Bad request. The product price should be digits') + + + def test_create_sale_order_invalid_quantity(self): + """Test POST /saleorder + + with the quantity not a valid integer + """ + self.register_test_admin_account() + token = self.login_test_admin() + + response = self.app_test_client.post('{}/saleorder'.format( + self.BASE_URL), json={'product_name': "Nyundo", 'product_price': 300, 'quantity': "1"}, + headers=dict(Authorization=token), + content_type='application/json') + + self.assertEqual(response.status_code, 400) + self.assertEqual(helper_functions.convert_response_to_json( + response)['message'], 'Bad request. The quantity should be specified in digits') \ No newline at end of file diff --git a/instance/config.py b/instance/config.py new file mode 100644 index 0000000..7ef9c3e --- /dev/null +++ b/instance/config.py @@ -0,0 +1,35 @@ +import os + + +class Config(object): + """Parent configuration class.""" + DEBUG = True + + +class Development(Config): + """Configurations for Development.""" + DEBUG = True + + +class Testing(Config): + """Configurations for Testing, with a separate test database.""" + TESTING = True + DEBUG = True + +class Staging(Config): + """Configurations for Staging.""" + DEBUG = True + + +class Production(Config): + """Configurations for Production.""" + DEBUG = False + TESTING = False + + +config = { + 'development': Development, + 'testing': Testing, + 'staging': Staging, + 'production': Production +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..22ab13e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,43 @@ +aniso8601==3.0.2 +asn1crypto==0.24.0 +astroid==2.0.4 +atomicwrites==1.2.1 +attrs==18.2.0 +certifi==2018.8.24 +cffi==1.11.5 +chardet==3.0.4 +Click==7.0 +coverage==4.0.3 +coveralls==1.5.1 +cryptography==2.3.1 +docopt==0.6.2 +Flask==1.0.2 +Flask-JWT-Extended==3.13.1 +Flask-RESTful==0.3.6 +gunicorn==19.9.0 +idna==2.7 +isort==4.3.4 +itsdangerous==0.24 +Jinja2==2.10 +lazy-object-proxy==1.3.1 +MarkupSafe==1.0 +mccabe==0.6.1 +more-itertools==4.3.0 +pluggy==0.7.1 +psycopg2-binary==2.7.5 +py==1.7.0 +pycparser==2.19 +PyJWT==1.6.4 +pylint==2.1.1 +pytest==3.8.2 +python-coveralls==2.9.1 +python-dotenv==0.9.1 +pytz==2018.5 +PyYAML==3.13 +requests==2.19.1 +six==1.11.0 +typed-ast==1.1.0 +urllib3==1.23 +validate-email==1.3 +Werkzeug==0.14.1 +wrapt==1.10.11 diff --git a/run.py b/run.py new file mode 100644 index 0000000..3da315a --- /dev/null +++ b/run.py @@ -0,0 +1,10 @@ +import os + +from app import create_app + + +app = create_app('production') + + +if __name__ == '__main__': + app.run(debug=True) \ No newline at end of file