diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..7bc352f --- /dev/null +++ b/.coverage @@ -0,0 +1 @@ +!coverage.py: This is a private format, don't read it directly!{"lines":{"/root/Documents/save_me/storeManager/app/__init__.py":[1],"/root/Documents/save_me/storeManager/app/app.py":[2,3,4,5,7,8,9,10,14,15,18,19,20,23,24,25,26,27,28,30,31,35,36,39,40,41,44,45,46,47,48,49,51,52,59,60,61,62,63,64,69,70,71,72,73,74,75,81,82,84,85,86,91,92,93,94,95,96,101,102,103,104,105,111,112,113,116,117,125,127,128,129,131,132,134,135,137,138,140,141,146,150,151,155,163,171,179,187,188,190,245,250,286,287,290,301,309,326,340,341,342,368,374,394,395,396,416,426,442,455,456,457,460,461,462,463,343,344,345,346,349,350,351,354,355,357,358,361,362,363,366,370,371,397,398,399,402,403,406,407,410,411,414,417,419,423,165,166,167,157,158,376,379,380,381,382,383,384,387,390,391,420,444,446,448,451,428,430,432,434,435,439,431,173,174,196,197,198,199,200,203,204,205,207,208,209,211,212,213,217,218,219,220,222,223,224,226,227,228,230,231,232,234,236,237,239,240,241,243,247,291,292,293,294,295,296,297,299,302,304,307,251,254,255,256,257,258,259,260,261,262,263,264,265,266,270,272,273,274,279,282,283,181,182,183,305,327,328,331,333,336,310,311,314,317,320,321,324,315],"/root/Documents/save_me/storeManager/app/api/app.py":[],"/root/Documents/save_me/storeManager/app/api/__init__.py":[]}} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1d4863 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.DS_Store + +__pycache__/ +*.py[cod] +*$py.class + +# virtualenv +.pyvirtual/ +pyvirtual/ +env/ + diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a1be49e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +language: python +python: + - "3.6" +cache: pip + +install: + - pip install -r requirements.txt + +script: + - coverage run -m pytest + - py.test --cov=app + +after_success: + - coveralls diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..be5721c --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: gunicorn app.app:my_app --preload --workers 4 diff --git a/README.md b/README.md index 9554b15..d0981f6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ # storeManager + +[![Coverage Status](https://coveralls.io/repos/github/hogum/storeManager/badge.svg)](https://coveralls.io/github/hogum/storeManager) + +[![Code Climate](https://codeclimate.com/github/codeclimate/codeclimate/badges/gpa.svg)](https://codeclimate.com/github/hogum/storeManager) + +[![GitHub issues](https://img.shields.io/github/issues/hogum/storeManager.svg?style=for-the-badge)](https://github.com/hogum/storeManager/issues) + A web application to help store managers maintain their inventories and manage sale records. + + +Try on Heroku: https://store-man90.herokuapp.com/stman/api/v1.0/products + + +[![License: GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) diff --git a/app/.coverage b/app/.coverage new file mode 100644 index 0000000..55093b6 --- /dev/null +++ b/app/.coverage @@ -0,0 +1 @@ +!coverage.py: This is a private format, don't read it directly!{"lines":{"/root/Documents/save_me/storeManager/app/api/app.py":[],"/root/Documents/save_me/storeManager/app/api/__init__.py":[]}} \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..7295c33 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,4 @@ +from .app import create_app + +def app_instance(app_setting): + return create_app(app_setting) diff --git a/app/api/app.py b/app/api/app.py new file mode 100644 index 0000000..c59715e --- /dev/null +++ b/app/api/app.py @@ -0,0 +1,29 @@ +from flask import Flask, Blueprint +from flask_restful import Api +from .views import product_views, sale_views, user_views +from app.instance.config import app_config + +store_blueprint = Blueprint("store-man", __name__) + + +def create_app(config_setting): + my_app = Flask(__name__) + my_app.config.from_object(app_config[config_setting]) + api = Api(store_blueprint) + + api.add_resource(sale_views.SalesAPI, + '/stman/api/v1.0/sales', endpoint='sales') + api.add_resource(product_views.ProductsAPI, + '/stman/api/v1.0/products', endpoint='products') + api.add_resource(sale_views.SaleAPI, + '/stman/api/v1.0/sales/', + endpoint='sale') + api.add_resource(product_views.ProductAPI, + '/stman/api/v1.0/products/', + endpoint='product') + api.add_resource(user_views.UsersAPI, + '/stman/api/v1.0/users/', + endpoint='users') + + my_app.register_blueprint(store_blueprint) + return my_app diff --git a/app/api/models/__init__.py b/app/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/models/products.py b/app/api/models/products.py new file mode 100644 index 0000000..9aa52cc --- /dev/null +++ b/app/api/models/products.py @@ -0,0 +1,22 @@ +from datetime import datetime + + +products = [] + +product_record_example = { + 'title': 'Innocent Coconut Water', + 'category': 'Women sure can Sleep', + 'price': 94534, + 'in stock': True, + 'date received': datetime( + 2018, 5, 30, 22, 12, 38, 649), + 'id': 2 +} + + +class Products(object): + @staticmethod + def productsList(): + products.append(product_record_example) + + return products diff --git a/app/api/models/sales.py b/app/api/models/sales.py new file mode 100644 index 0000000..c485649 --- /dev/null +++ b/app/api/models/sales.py @@ -0,0 +1,34 @@ +from datetime import datetime + +sales = [] + +# example of a sale record + +sale_example = { + 'sales_record': 1, + 'attendant': u'Attendant One', + + # Customer contacts + 'name': u'Customer One', + 'address': u'45 bright street', + 'contact': [u'+00012345', u'customer_c@example.co'], + + # Transaction Info + 'product': u'Spam 2.0', + 'quantity': 1, + 'date': datetime(2018, 6, 6, 5, 28, 56, 243), + 'description': u'Hot with extra extra spam', + 'transaction_type': u'Cash on Delivery', + 'complete': False, + + 'gifts': 100, # Anything to reduce sale e.g discounts + 'price': 276, +} + + +class Sales(): + @staticmethod + def salesList(): + sales.append(sale_example) + + return sales diff --git a/app/api/models/users.py b/app/api/models/users.py new file mode 100644 index 0000000..5b684a1 --- /dev/null +++ b/app/api/models/users.py @@ -0,0 +1,27 @@ +from werkzeug.security import generate_password_hash + +users = [] + +cool_user_sample = { + 'name': 'Evil Cow', + 'username': 'e-cow', + 'email': 'ecow@isus.mammals', + 'password': generate_password_hash('wah!-things-we-do!'), + 'user id': 1 +} + + +class Users(object): + """docstring for Users""" + def __init__(self, name, username, email, password): + super(Users, self).__init__() + self.name = name + self.username = username + self.email = email + self.password = password + + @staticmethod + def usersList(): + users.append(cool_user_sample) + + return users diff --git a/app/api/views/__init__.py b/app/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/api/views/product_views.py b/app/api/views/product_views.py new file mode 100644 index 0000000..a02e399 --- /dev/null +++ b/app/api/views/product_views.py @@ -0,0 +1,131 @@ +from flask_restful import Resource, reqparse, marshal, fields +from app.api.models import products +from datetime import datetime +from flask import abort + +products = products.Products.productsList() + + +class ProductsAPI(Resource): + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('title', type=str, required=True, + help="Please add a title", + location='json' + ) + + self.parse.add_argument('category', type=str, + default='None', + location='json' + ) + + self.parse.add_argument('price', type=int, + required=True, + help="You are not \ + allowed to give out stuff for free", + location='json' + ) + + self.parse.add_argument('in stock', type=bool, + default=True, + location='json' + ) + + super(ProductsAPI, self).__init__() + + def get(self): + return { + 'product': [marshal(product, product_fields) + for product in products] + } + + def post(self): + elements = self.parse.parse_args() + + product = { + 'title': elements['title'], + 'category': elements['category'], + 'price': elements['price'], + 'in stock': True, + 'date received': datetime.now(), + 'id': products[-1]['id'] + 1 + } + + products.append(product) + + return { + 'product': marshal(product, product_fields) + }, 201 + + +class ProductAPI(Resource): + """docstring for ProductAPI""" + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('title', type=str, + location='json' + ) + + self.parse.add_argument('category', type=str, + location='json' + ) + + self.parse.add_argument('price', type=int, + location='json' + ) + + self.parse.add_argument('in stock', type=bool, + location='json' + ) + + super(ProductAPI, self).__init__() + + def get(self, id): + product = [product for product in + products if product['id'] is id] + + if not product: + abort(404) + + return { + 'product': marshal(product[0], product_fields) + } + + def put(self, id): + product = [product for product in + products if product['id'] is id] + + if not product: + abort(404) + elements = self.parse.parse_args() + + for key, value in list(elements.items()): + if value: + product[0][key] = value + + return { + 'product': marshal(product, product_fields) + } + + def delete(self, id): + product = [product for product + in products if product['id'] is id] + + if not product: + abort(404) + products.remove(product[0]) + + return { + 'Status': True + } + + +product_fields = { + 'title': fields.String, + 'category': fields.String, + 'price': fields.Integer, + 'in stock': fields.Boolean, + 'date received': fields.DateTime, + 'url': fields.Url('product') # Ensure user doen't + # need to know how to generate url +} diff --git a/app/api/views/sale_views.py b/app/api/views/sale_views.py new file mode 100644 index 0000000..98e6ca0 --- /dev/null +++ b/app/api/views/sale_views.py @@ -0,0 +1,196 @@ +from flask_restful import Resource, reqparse, marshal, fields +from app.api.models import sales +from datetime import datetime +from flask import abort + +sales = sales.Sales.salesList() + + +class SalesAPI(Resource): + def __init__(self): + + self.parse = reqparse.RequestParser() + self.parse.add_argument('attendant', type=str, + required=True, + help="A sale need's an attendant", + location='json') + + # Customer details + self.parse.add_argument('name', type=str, + default='Anonymous', + location='json') + + self.parse.add_argument('address', type=str, + default='Unknown', + location='json') + + self.parse.add_argument('contact', type=list, + default=['phone', 'email'], + location='json') + + # transaction details + + self.parse.add_argument('product', type=str, + required=True, + help="A product to sell sure has a name", + location='json') + + self.parse.add_argument('quantity', type=int, + help="How many items", default=1, + location='json') + + self.parse.add_argument('transaction_type', type=str, + default='Cash on Delivery', + location='json') + + self.parse.add_argument('gifts', type=int, + default='0', + location='json') + + self.parse.add_argument('price', type=int, required=True, + help="""You sure are not giving it away for free + """, + location='json') + + self.parse.add_argument('description', type=str, + default='', + location='json') + + super(SalesAPI, self).__init__() + + def get(self): + return { + 'sales': [marshal(sale, sale_fields) for sale in sales] + } + + def post(self): + elements = self.parse.parse_args() + + sale = { + 'sales_record': sales[-1]['sales_record'] + 1, + 'attendant': elements['attendant'], + 'name': elements['name'], + 'address': elements['address'], + 'contact': elements['contact'], + 'product': elements['product'], + 'quantity': elements['quantity'], + 'date': datetime.now(), + 'description': elements['description'], + 'transaction_type': elements['transaction_type'], + 'complete': False, + 'gifts': 0, + 'price': elements['price'] + } + + # Find total cost of sale + sale.update( + { + 'total': sale.get('quantity') * + sale.get('price') - + sale.get('gifts') + } + ) + + # Add new sale to sales record + sales.append(sale) + + return { + 'sale': marshal(sale, sale_fields) + }, 201 + + +class SaleAPI(Resource): + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('attendant', type=str, location='json') + self.parse.add_argument('transaction_info', type=dict, location='json') + self.parse.add_argument('gifts', type=int, location='json') + self.parse.add_argument('total', + type=float, + location='json' + ) + super(SaleAPI, self).__init__() + + def get(self, sales_record): + sale = [sale for sale in sales if sale[ + 'sales_record'] == sales_record] + + if not sale: + abort(404) + + return {'sale': marshal(sale[0], sale_fields)} + + def put(self, sales_record): + sale = [sale for sale + in sales if sale['sales_record'] is sales_record + ] + + if not sale: + abort(404) + + elements = self.parse.parse_args() + + # update any changed element + for key, value in list(elements.items()): + if value: + sale[0][key] = value + + return {'sale': marshal(sale[0], sale_fields)} + + def delete(self, sales_record): + sale = [sale for sale + in sales if sale['sales_record'] is sales_record + ] + + if not sale: + abort(404) + sales.remove(sale[0]) + + return { + 'Effect': True + } + + +sale_fields = { + 'sales_uri': fields.Url('sale'), + 'attendant': fields.String, + 'gifts': fields.Integer, + 'price': fields.Integer, + 'total': fields.Integer +} + +""" + Output nested customer details + """ +sale_fields['customer'] = {} +sale_fields['customer']['Name'] = fields.String(attribute='name') +sale_fields['customer']['Address'] = fields.String(attribute='address') + +# Create list for customer contacts +sale_fields['customer']['Contact'] = fields.List( + fields.String, attribute='contact' +) + + +""" + Nest the transaction info + """ + +sale_fields['transaction_info'] = {} + +sale_fields['transaction_info']['Product'] = fields.String(attribute='product') +sale_fields['transaction_info']['Quantity'] = fields.Integer( + attribute='quantity' +) +sale_fields['transaction_info']['Date'] = fields.DateTime( + attribute='date', dt_format='rfc822' +) +sale_fields['transaction_info']['Description'] = fields.String( + attribute='description' +) +sale_fields['transaction_info']['Transaction_type'] = fields.String( + attribute='transaction_type' +) +sale_fields['transaction_info']['Complete'] = fields.Boolean( + attribute='complete' +) diff --git a/app/api/views/user_views.py b/app/api/views/user_views.py new file mode 100644 index 0000000..17a4407 --- /dev/null +++ b/app/api/views/user_views.py @@ -0,0 +1,77 @@ +from flask_restful import Resource, reqparse, marshal, fields +from app.api.models import users +from flask import jsonify, make_response +import random + +users = users.Users.usersList() + + +class UsersAPI(Resource): + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('name', type=str, required=True, + help="Please add a name", + location='json' + ) + + self.parse.add_argument('username', type=str, + default='user' + + str(random.randint(500, 5000)), + location='json' + ) + + self.parse.add_argument('email', type=str, + required=True, + help="You are not \ + allowed here without email", + location='json' + ) + + self.parse.add_argument('password', type=str, + required=True, + help='Please specify password', + location='json' + ) + + super(UsersAPI, self).__init__() + + def get(self): + return { + 'users': [marshal(user, user_fields) + for user in users] + } + + def post(self): + elements = self.parse.parse_args() + + user = { + 'name': elements['name'], + 'username': elements['username'], + 'email': elements['email'], + 'password': elements['password'], + 'user id': users[-1]['user id'] + 1 + } + + present = [userr for userr in users + if userr['email'] is elements['email']] + + if not present: + users.append(user) + + else: + return make_response(jsonify({'message': + 'That email is already registered'}), + 409) + + return { + 'Effect': 'Success. User added' + }, 201 + + +user_fields = { + 'name': fields.String, + 'email': fields.String, + 'username': fields.Integer, + 'password': fields.Boolean, + 'url': fields.Url('user id') +} diff --git a/app/app.py b/app/app.py index 3599952..798c4e3 100644 --- a/app/app.py +++ b/app/app.py @@ -1,46 +1,463 @@ -from flask import Flask, jsonify +from flask import Flask, abort, jsonify, make_response +from flask_restful import Api, fields, Resource, reqparse, marshal +from datetime import datetime +from flask_httpauth import HTTPBasicAuth +my_app = Flask(__name__, static_url_path="") +api = Api(my_app) +admin_auth = HTTPBasicAuth() +auth = HTTPBasicAuth() sales = [ { 'sales_record': 1, 'attendant': u'Attendant One', + + # Customer contacts + 'name': u'Customer One', + 'address': u'45 bright street', + 'contact': [u'+00012345', u'customer_c@example.co'], + + # Transaction Info 'product': u'Spam 2.0', - 'quantity': u'1', - 'complete': False, + 'quantity': 1, + 'date': datetime(2018, 6, 6, 5, 28, 56, 243), 'description': u'Hot with extra extra spam', - 'customer': { - 'name': u'Customer One', - 'address': u'45 bright street', - 'contact': [u'+00012345', - u'customer_one@example.co' - ] - }, - 'transaction type': u'Cash on Delivery', - 'gifts': None, - 'total for this sale': u'Ksh 276' + 'transaction_type': u'Cash on Delivery', + 'complete': False, + + 'gifts': 100, # Anything to reduce sale e.g discounts + 'price': 276, }, { 'sales_record': 2, 'attendant': u'Attendant Six', - 'product': u'Spam 2.0', - 'quantity': u'1', - 'complete': False, - 'description': u'Black with red eatable margins', - 'customer': { - 'name': u'Customer fifty', - 'address': u'42 bright street', - 'contact': [u'+00012345', - u'customer_c@example.co' - ] - }, - 'transaction type': u'Credit Card', - 'gifts':None, - 'total for this sale': u'Ksh 355' + # Customer contacts + 'name': u'Customer fifty', + 'address': u'42 bright street', + 'contact': [u'+00012345', u'customer_c@example.co'], + + # Transaction Info + 'product': u'Spam 2.2', + 'quantity': 1, + 'date': datetime(2018, 3, 16, 10, 3, 10, 7345), + 'description': u'Black with red eatable margins', + 'transaction_type': u'Credit', + 'complete': False, + 'gifts': 0, # Any reduction in sale price + 'price': 355 } ] + +products = [ + { + 'title': 'Bacon&Spam', + 'category': 'Hair Likes Food', + 'price': 934, + 'in stock': True, + 'date received': datetime(2018, 7, 10, 4, 2, 8, 564), + 'id': 1 + + }, + + { + 'title': 'Innocent Coconut Water', + 'category': 'Women sure can Sleep', + 'price': 94534, + 'in stock': True, + 'date received': datetime( + 2018, 5, 30, 22, 12, 38, 649), + 'id': 2 + } +] + +# calculate total cost of each sale + +for each_sale in sales: + each_sale.update( + { + 'total': each_sale.get('quantity') * + each_sale.get('price') - + each_sale.get('gifts') + } + ) + +product_fields = { + 'title': fields.String, + 'category': fields.String, + 'price': fields.Integer, + 'in stock': fields.Boolean, + 'date received': fields.DateTime, + 'url': fields.Url('product') # Ensure user doen't + # need to know how to generate url +} + +sale_fields = { + 'sales_uri': fields.Url('sale'), + 'attendant': fields.String, + 'gifts': fields.Integer, + 'price': fields.Integer, + 'total': fields.Integer +} + +""" + Output nested customer details + """ +sale_fields['customer'] = {} +sale_fields['customer']['Name'] = fields.String(attribute='name') +sale_fields['customer']['Address'] = fields.String(attribute='address') + +# Create list for customer contacts +sale_fields['customer']['Contact'] = fields.List( + fields.String, attribute='contact' +) + + +""" + Nest the transaction info + """ + +sale_fields['transaction_info'] = {} + +sale_fields['transaction_info']['Product'] = fields.String(attribute='product') +sale_fields['transaction_info']['Quantity'] = fields.Integer( + attribute='quantity' +) +sale_fields['transaction_info']['Date'] = fields.DateTime( + attribute='date', dt_format='rfc822' +) +sale_fields['transaction_info']['Description'] = fields.String( + attribute='description' +) +sale_fields['transaction_info']['Transaction_type'] = fields.String( + attribute='transaction_type' +) +sale_fields['transaction_info']['Complete'] = fields.Boolean( + attribute='complete' +) + + +admin_users = { + 'manager': 'man', +} + +users = { + 'manager': 'man', + 'attendant': 'att' +} + + +@admin_auth.get_password +def use_password(username): + if username in users: + return admin_users.get(username) + + return None + + +@admin_auth.error_handler +def restricted(): + return make_response(jsonify({ + 'message': "Access not allowed" + }), 403 + ) + + +@auth.get_password +def u_password(username): + if username in users: + return users.get(username) + + return None + + +@auth.error_handler +def restrict(): + return make_response(jsonify({ + 'message': "Access not allowed" + }), 403 + ) + + +class AllSalesAPI(Resource): + decorators = [auth.login_required] + + def __init__(self): + + """ + Verify arguments' are in correct type. + """ + + self.parse = reqparse.RequestParser() + self.parse.add_argument('attendant', type=str, + required=True, + help="A sale need's an attendant", + location='json') + + # Customer details + self.parse.add_argument('name', type=str, + default='Anonymous', + location='json') + + self.parse.add_argument('address', type=str, + default='Unknown', + location='json') + + self.parse.add_argument('contact', type=list, + default=['phone', 'email'], + location='json') + + # transaction details + + self.parse.add_argument('product', type=str, + required=True, + help="A product to sell sure has a name", + location='json') + + self.parse.add_argument('quantity', type=int, + help="How many items", default=1, + location='json') + + self.parse.add_argument('transaction_type', type=str, + default='Cash on Delivery', + location='json') + + self.parse.add_argument('gifts', type=int, + default='0', + location='json') + + self.parse.add_argument('price', type=int, required=True, + help="""You sure are not giving it away for free + """, + location='json') + + self.parse.add_argument('description', type=str, + default='', + location='json') + + super(AllSalesAPI, self).__init__() + + def get(self): + return { + 'sales': [marshal(sale, sale_fields) for sale in sales] + } + + def post(self): + elements = self.parse.parse_args() + + sale = { + 'sales_record': sales[-1]['sales_record'] + 1, + 'attendant': elements['attendant'], + 'name': elements['name'], + 'address': elements['address'], + 'contact': elements['contact'], + 'product': elements['product'], + 'quantity': elements['quantity'], + 'date': datetime.now(), + 'description': elements['description'], + 'transaction_type': elements['transaction_type'], + 'complete': False, + 'gifts': 0, + 'price': elements['price'] + } + + # Find total cost of sale + sale.update( + { + 'total': sale.get('quantity') * + sale.get('price') - + sale.get('gifts') + } + ) + + # Add new sale to sales record + sales.append(sale) + + return { + 'sale': marshal(sale, sale_fields) + }, 201 + + +class SaleAPI(Resource): + decorators = [admin_auth.login_required] + + """docstring for SaleAPI""" + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('attendant', type=str, location='json') + self.parse.add_argument('transaction_info', type=dict, location='json') + self.parse.add_argument('gifts', type=int, location='json') + self.parse.add_argument('total', + type=float, + location='json' + ) + super(SaleAPI, self).__init__() + + def get(self, sales_record): + sale = [sale for sale in sales if sale['sales_record'] == sales_record] + + if not sale: + abort(404) + + return {'sale': marshal(sale[0], sale_fields)} + + def put(self, sales_record): + sale = [sale for sale + in sales if sale['sales_record'] is sales_record + ] + + if not sale: + abort(404) + + elements = self.parse.parse_args() + + # update any changed element + for key, value in list(elements.items()): + if value: + sale[0][key] = value + + return {'sale': marshal(sale[0], sale_fields)} + + def delete(self, sales_record): + sale = [sale for sale + in sales if sale['sales_record'] is sales_record + ] + + if not sale: + abort(404) + sales.remove(sale[0]) + + return { + 'Effect': True + } + + +class AllProductsAPI(Resource): + """docstring for AllProductsAPI""" + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('title', type=str, required=True, + help="Please add a title", + location='json' + ) + + self.parse.add_argument('category', type=str, + default='None', + location='json' + ) + + self.parse.add_argument('price', type=int, + required=True, + help="You are not \ + allowed to give out stuff for free", + location='json' + ) + + self.parse.add_argument('in stock', type=bool, + default=True, + location='json' + ) + + super(AllProductsAPI, self).__init__() + + def get(self): + return { + 'product': [marshal(product, product_fields) + for product in products] + } + + @admin_auth.login_required + def post(self): + elements = self.parse.parse_args() + + product = { + 'title': elements['title'], + 'category': elements['category'], + 'price': elements['price'], + 'in stock': True, + 'date received': datetime.now(), + 'id': products[-1]['id'] + 1 + } + + products.append(product) + + return { + 'product': marshal(product, product_fields) + }, 201 + + +class ProductAPI(Resource): + """docstring for ProductAPI""" + def __init__(self): + self.parse = reqparse.RequestParser() + self.parse.add_argument('title', type=str, + location='json' + ) + + self.parse.add_argument('category', type=str, + location='json' + ) + + self.parse.add_argument('price', type=int, + location='json' + ) + + self.parse.add_argument('in stock', type=bool, + location='json' + ) + + super(ProductAPI, self).__init__() + + def get(self, id): + product = [product for product in products if product['id'] is id] + + if not product: + abort(404) + + return { + 'product': marshal(product[0], product_fields) + } + + @admin_auth.login_required + def put(self, id): + product = [product for product in products if product['id'] is id] + + if not product: + abort(404) + elements = self.parse.parse_args() + + for key, value in list(elements.items()): + if value: + product[0][key] = value + + return { + 'product': marshal(product, product_fields) + } + + @admin_auth.login_required + def delete(self, id): + product = [product for product in products if product['id'] is id] + + if not product: + abort(404) + product.remove(product[0]) + + return { + 'Status': True + } + + +api.add_resource(AllSalesAPI, '/stman/api/v1.0/sales', endpoint='sales') +api.add_resource(SaleAPI, '/stman/api/v1.0/sales/', + endpoint='sale' + ) + +api.add_resource(AllProductsAPI, '/stman/api/v1.0/products', + endpoint='products') +api.add_resource(ProductAPI, '/stman/api/v1.0/products/', + endpoint='product') diff --git a/app/instance/__init__.py b/app/instance/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/instance/config.py b/app/instance/config.py new file mode 100644 index 0000000..53c7846 --- /dev/null +++ b/app/instance/config.py @@ -0,0 +1,38 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + + +class Config(object): + DEBUG = False + TESTING = False + CSRF_ENABLED = True + SECRET_KEY = 'secrtet-key-secrete' + + +class ProductionConfig(Config): + DEBUG = False + + +class StagingConfig(Config): + DEVELOPMENT = True + DEBUG = True + + +class DevelopmentConfig(Config): + DEVELOPMENT = True + DEBUG = True + + +class StagingConfig(Config): + DEBUG = True + +class TestingConfig(Config): + DEBUG = True + + +app_config = { + 'development': DevelopmentConfig, + 'testing': TestingConfig, + 'staging': StagingConfig, + 'production': ProductionConfig +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..31369dd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,31 @@ +aniso8601==3.0.2 +atomicwrites==1.2.1 +attrs==18.2.0 +certifi==2018.10.15 +chardet==3.0.4 +Click==7.0 +coverage==4.5.1 +coveralls==1.5.1 +docopt==0.6.2 +Flask==1.0.2 +Flask-HTTPAuth==3.2.4 +Flask-RESTful==0.3.6 +gunicorn==19.9.0 +idna==2.7 +itsdangerous==0.24 +Jinja2==2.10 +MarkupSafe==1.0 +mock==2.0.0 +more-itertools==4.3.0 +pbr==5.0.0 +pluggy==0.8.0 +py==1.7.0 +pytest==3.9.1 +pytest-cov==2.6.0 +python-coveralls==2.9.1 +pytz==2018.5 +PyYAML==3.13 +requests==2.20.0 +six==1.11.0 +urllib3==1.24 +Werkzeug==0.14.1 diff --git a/run.py b/run.py index 86c4d03..bf26909 100644 --- a/run.py +++ b/run.py @@ -1,4 +1,6 @@ -from app import app +from app import api + +app = api.app_instance('development') if __name__ == '__main__': - app.run() + app.run(debug=True) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_products.py b/tests/test_products.py new file mode 100644 index 0000000..9de2b8d --- /dev/null +++ b/tests/test_products.py @@ -0,0 +1,326 @@ +import unittest +from app import app +import mock +from base64 import b64encode +import json +import sys +from random import randint + +my_app = app.my_app + + +class BasicProductTests(unittest.TestCase): + def setUp(self): + my_app.testing = True + self.app = my_app.test_client() + self.path = '/stman/api/v1.0/products' + self.headers = { # User credentials to test + 'Authorization': 'Basic %s' % b64encode( + b"manager:man").decode("ascii") + + } + self.single_path = '/stman/api/v1.0/products' + '/' + '1' + + self.response_get = self.app.get(self.path, + follow_redirects=True + ) + self.response_get_auth = self.app.get(self.path, + headers=self.headers, + follow_redirects=True + ) + self.response_get_product = self.app.get(self.single_path, + headers=self.headers, + follow_redirects=True + ) + + # Load json data to compare with raw data + self.response_unpack_get = json.loads( + self.response_get.get_data().decode(sys.getdefaultencoding()) + ) + + def test_access_without_credentials(self): + """ + Credentials not required not view products + """ + self.assertNotEqual(self.response_get.status_code, 403, + "Failed to show \ + products without requesting for credentials") + + self.assertNotEqual(self.response_get_auth.status_code, 403, + "Failed to show \ + products without requesting for credentials") + + def test_get_products(self): + # check data request comes in a dictionary + + response_unpack = json.loads( + self.response_get.get_data().decode(sys.getdefaultencoding()) + ) + + self.assertTrue(isinstance(response_unpack, dict), + msg="Failed to output Records in a dictionary") + + def test_get_products_contents(self): + """ + Product records are lists wrapped in dicts + """ + self.assertIsInstance(self.response_unpack_get['product'], list, + msg="Failed to give record as a list of products" + ) + + def test_fielding_of_ouputs(self): + # Check if fields give back data as nested output + + self.assertFalse(app.products is self.response_unpack_get['product'], + msg="Failed to group Product record details" + ) + + def test_access_to_post_products(self): + """ + Verify that only allowed users(manager) can create product. + """ + + response_post_product = self.app.post(self.path, + follow_redirects=True + ) + self.assertTrue(response_post_product.status_code == 403, + msg="Fails to deny\ + anuthorized user access to create new item") + + def test_post_product(self): + """ + Verify that necessary arguments are passed for + needed for product to be created. + """ + + response_post_product = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + missing_price='Call her A', + title='Almost Ours', + category="I'm taking her\ + to coffee")), + content_type='application/json', + follow_redirects=True + ) + response_unpack = json.loads( + response_post_product.get_data().decode(sys.getdefaultencoding()) + ) + fail_resp = "You are not \ + allowed to give out stuff for free" + + self.assertIn(fail_resp, response_unpack['message']['price'], + msg="Fails to request \ + user for required arguments") + + def test_make_sensible_post_for_product(self): + """ + Verify that a new product can be created. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + title='Loose a Screw', + price=7854, + category="Vodkaless Alcohol")), + content_type='application/json', + follow_redirects=True + ) + + self.assertTrue(response_post_sale.status_code is 201, + msg="Fails to create new product") + + def test_post_product_using_unknown_details(self): + """ + Verify that unknown data arguments do not create new details + for the new sale. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + I_='Somebody I know', + got=354, + drunk='Baby Soap', + a_little='absent')), + content_type='application/json', + follow_redirects=True + ) + response_unpack = json.loads( + response_post_sale.get_data().decode(sys.getdefaultencoding()) + ) + + self.assertRaises(KeyError, lambda: response_unpack[ + 'sale']['drunk']), + "Fails to ignore unknown product details" + + def test_post_product_really_creates_product(self): + """ + Verify that the created product gets a uri. + This uri should be added to those of existing products. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + title='Call me A', + price=354, + category='Bubbless Soap')), + content_type='application/json', + follow_redirects=True + ) + response_unpack_post = json.loads( + response_post_sale.get_data().decode(sys.getdefaultencoding()) + ) + + # Request we get from fetch-all-products + response_unpack_get = json.loads( + self.response_get.get_data().decode(sys.getdefaultencoding()) + ) + + # Uri we created for this product + product_uri = int(response_unpack_post['product']['url'][-1]) + + # Uri we had last + last_product_known = int( + response_unpack_get[ + 'product'][-1]['url'][-1]) + 1 + + self.assertEqual(product_uri, last_product_known, + msg="Fails to add new sale to existing sales") + + def test_put_for_Products(self): + """ + We are 'not supposed to be able' to add + data to the Proucts resource. + 'Put' should fail. + """ + what_we_expect = "The method is not allowed for the requested URL." + resput_sales = self.app.put(self.path, + headers=self.headers, + data=json.dumps(dict( + this=1, + thing=2, + fails=3)), + follow_redirects=True + ) + response_unpack = json.loads( + resput_sales.get_data(). + decode(sys.getdefaultencoding()) + ) + + self.assertTrue(response_unpack['message'] == what_we_expect, + msg="Fails to give \ + error for use of a nonexistent method") + + +class BasicSingleProductTests(BasicProductTests): + def test_guess_path_to_product(self): + guessed_path = self.path + '/' + str(randint(2345, 998888)) + response = self.app.get(guessed_path, + headers=self.headers, + follow_redirects=True + ) + + self.assertTrue(response.status_code == 404, + msg="Failed to return\ + 'not found error' for nonexistent url" + ) + + def test_product_output_uri(self): + """ + Check that the Product url element is added from the path + leading to the product. + """ + + response_unpack = json.loads( + self.response_get_product.get_data(). + decode(sys.getdefaultencoding()) + ) + + self.assertEqual(response_unpack['product']['url'], + self.single_path) + + def test_delete_for_missing_resource(self): + guessed_path = self.path + '/' + str(randint(2345, 998888)) + response = self.app.get(guessed_path, + headers=self.headers, + follow_redirects=True + ) + + self.assertFalse(response.status_code == 200, + msg="Deletes a nonexistent sale" + ) + + def test_delete_product(self): + + # Try not remove 'path/../../1', I think every test around wants her + rm_path = self.path + '/' + '2' + resdel_prod = self.app.delete(rm_path, + headers=self.headers, + follow_redirects=True + ) + response_unpack = json.loads( + resdel_prod.get_data(). + decode(sys.getdefaultencoding()) + ) + resdel_prod = self.app.get(rm_path, + headers=self.headers, + follow_redirects=True + ) + # Assert delete + self.assertDictEqual(response_unpack, {"Status": True}, + msg="Failed to delete sale" + ) + + # Assert sale is discarded + self.assertNotEqual(resdel_prod.status_code, 404, + msg="Failed to remove deleted item from records" + ) + + def test_get_product_ouput(self): + # Product is received wrapped in dict + + response_unpack = json.loads( + self.response_get_product.get_data(). + decode(sys.getdefaultencoding()) + ) + + self.assertTrue(isinstance(response_unpack, dict)) + + def test_put_product(self): + """ + Verify an existing product can be updated + """ + resput_single_prod = self.app.put(self.single_path, + headers=self.headers, + data=json.dumps(dict( + this=1, + is_=2, + testing=3)), + content_type='application/json', + follow_redirects=True + ) + success = 200 + """ response_unpack = json.loads( + resput_single_sale.get_data(). + decode(sys.getdefaultencoding()) + ) """ + self.assertEqual(resput_single_prod.status_code, success) + + def test_put_product_using_bad_url(self): + """ + Verify a resource can be updated + """ + path = self.path + '/' + '664' + resput_single_prod = self.app.put(path, + headers=self.headers, + data=json.dumps(dict( + one=1, + two=2, + JUMP=3)), + follow_redirects=True + ) + missing = 404 + self.assertEqual(resput_single_prod.status_code, missing) diff --git a/tests/test_sales.py b/tests/test_sales.py new file mode 100644 index 0000000..880254c --- /dev/null +++ b/tests/test_sales.py @@ -0,0 +1,341 @@ + +import unittest +from app import app +import mock +from base64 import b64encode +import json +import sys +from random import randint + +my_app = app.my_app + + +class BasicSaleTests(unittest.TestCase): + def setUp(self): + my_app.testing = True + self.app = my_app.test_client() + self.path = '/stman/api/v1.0/sales' + self.headers = { # User credentials to test + 'Authorization': 'Basic %s' % b64encode( + b"manager:man").decode("ascii") + + } + self.response_get = self.app.get(self.path, + headers=self.headers, + follow_redirects=True + ) + + # Url Path to individual sale resource + self.single_sale_path = self.path + '/' + str(1) + self.response_single_sale = self.app.get(self.single_sale_path, + headers=self.headers, + follow_redirects=True + ) + + def test_start_authorization_without_credentials(self): + response = self.app.get(self.path, follow_redirects=True) + self.assertEqual(response.status_code, 403, + "Grants unauthorized access" + ) + + def test_call_for_AllSalesAPI(self): + # Generate url for resourse directly # context unavailable + + resrce = app.AllSalesAPI() + + self.assertRaises(RuntimeError, resrce.get), "Calls non-creatable url" + + def test_true_authorization_for_verified_user(self): + # Test for access using valid credentials + + self.assertEqual(self.response_get.status_code, 200, + "Denies verified user access" + ) + + def test_get_sales(self): + # check data request comes in a dictionary + + response_unpack = json.loads( + self.response_get.get_data().decode(sys.getdefaultencoding()) + ) + + self.assertTrue(isinstance(response_unpack, dict), + msg="Records not received back as dict") + + def test_fielding_of_ouputs(self): + # Check if sale record is given back as a nested response + + response_unpack = json.loads( + self.response_get.get_data().decode(sys.getdefaultencoding()) + ) + + self.assertFalse(app.sales is response_unpack['sales'], + msg="Sale record details not organised in groups" + ) + + def test_post_sale(self): + """ + Verify the passed arguments create new details + for the new sale. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + attendant='Call me A', + price=354, + product='Baby Soap')), + content_type='application/json', + follow_redirects=True + ) + response_unpack = json.loads( + response_post_sale.get_data().decode(sys.getdefaultencoding()) + ) + + self.assertEqual(response_unpack['sale']['price'], 354, + "Sale not created with given data") + + def test_post_sale_arguments(self): + """ + Verify required arguments are passed for + resource to be created. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + attend='Call me A', + price=354, + product='Baby Soap')), + content_type='application/json', + follow_redirects=True + ) + response_unpack = json.loads( + response_post_sale.get_data().decode(sys.getdefaultencoding()) + ) + fail_resp = {'attendant': "A sale need's an attendant"} + + self.assertDictEqual(fail_resp, response_unpack['message'], + msg="Fails to request \ + user for required arguments") + + def test_make_correct_post_sale(self): + """ + Verify that, with correct arguments, + a sale record is created + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + attendant='Her Again', + price=354, + product='Baby Soap')), + content_type='application/json', + follow_redirects=True + ) + + self.assertTrue(response_post_sale.status_code is 201, + msg="Fails to create new sale record") + + def test_post_sale_forbidden_arguments(self): + """ + Verify that unknown data arguments do not create new details + for the new sale. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + attendant='Somebody I know', + price=354, + product='Baby Soap', + missing_detail='absent')), + content_type='application/json', + follow_redirects=True + ) + response_unpack = json.loads( + response_post_sale.get_data().decode(sys.getdefaultencoding()) + ) + + self.assertRaises(KeyError, lambda: response_unpack[ + 'sale']['missing_detail']), + "Failed to ignore unrequired sale arguments" + + def test_post_sale_that_will_exists(self): + """ + Verify that the created is a brand new uri. + #actually do this ****************************** + Created sale is added to the existing sales. + """ + + response_post_sale = self.app.post(self.path, + headers=self.headers, + data=json.dumps(dict( + attendant='Call me A', + price=354, + product='Baby Soap')), + content_type='application/json', + follow_redirects=True + ) + response_unpack_post = json.loads( + response_post_sale.get_data().decode(sys.getdefaultencoding()) + ) + + # GET Request from all-sales uri + response_unpack_get = json.loads( + self.response_get.get_data().decode(sys.getdefaultencoding()) + ) + + # Uri for this sale + new_sale_uri = int(response_unpack_post['sale']['sales_uri'][-1]) + + # Uri for last sale + last_sale_uri = int( + response_unpack_get[ + 'sales'][-1]['sales_uri'][-1]) + 1 + + self.assertEqual(new_sale_uri, last_sale_uri, + msg="Fails to add new sale to existing sales") + + def test_put_for_AllSales(self): + """ + Our subclass Resource for 'sales' has no put method. + Check that changing of this resouce does not work + """ + how_flask_says_it = "The method is not allowed for the requested URL." + resput_sales = self.app.put(self.path, + headers=self.headers, + data=json.dumps(dict( + this=1, + thing=2, + fails=3)), + follow_redirects=True + ) + response_unpack = json.loads( + resput_sales.get_data(). + decode(sys.getdefaultencoding()) + ) + + self.assertEqual(response_unpack['message'], how_flask_says_it) + + +class BasicSingleSaleTest(BasicSaleTests): + def test_post_sale_that_will_exists(self): + pass # Interferes with working of super + + def test_randomized_uri(self): + guessed_path = self.path + '/' + str(randint(2345, 998888)) + response = self.app.get(guessed_path, + headers=self.headers, + follow_redirects=True + ) + + self.assertTrue(response.status_code == 404, + msg="Failed to return error for nonexistent url" + ) + + def test_sale_uri(self): + """ + Check that the Sale url element is added from the path + that leads to that sale. + """ + + response_unpack = json.loads( + self.response_single_sale.get_data(). + decode(sys.getdefaultencoding()) + ) + + self.assertEqual(response_unpack['sale']['sales_uri'], + self.single_sale_path) + + def test_sale_response_type(self): + # Response given as dict + + response_unpack = json.loads( + self.response_single_sale.get_data(). + decode(sys.getdefaultencoding()) + ) + + self.assertTrue(isinstance(response_unpack, dict)) + + def test_put_sale(self): + """ + Verify a resource can be updated + """ + resput_single_sale = self.app.put(self.single_sale_path, + headers=self.headers, + data=json.dumps(dict( + this=1, + sale=2, + fails=3)), + content_type='application/json', + follow_redirects=True + ) + success = 200 + """ response_unpack = json.loads( + resput_single_sale.get_data(). + decode(sys.getdefaultencoding()) + ) """ + self.assertEqual(resput_single_sale.status_code, success) + + def test_put_sale_with_wrong_url(self): + """ + Verify a resource can be updated + """ + path = self.path + '/' + '664' + resput_single_sale = self.app.put(path, + headers=self.headers, + data=json.dumps(dict( + this=1, + sale=2, + fails=3)), + follow_redirects=True + ) + missing = 404 + """ response_unpack = json.loads( + resput_single_sale.get_data(). + decode(sys.getdefaultencoding()) + ) """ + self.assertEqual(resput_single_sale.status_code, missing) + + def test_delete_sale(self): + + # Try not remove 'path/../../1', I think every test around wants her + rm_path = self.path + '/' + '2' + resdel_sale = self.app.delete(rm_path, + headers=self.headers, + follow_redirects=True + ) + response_unpack = json.loads( + resdel_sale.get_data(). + decode(sys.getdefaultencoding()) + ) + resdel_sale = self.app.get(rm_path, + headers=self.headers, + follow_redirects=True + ) + # Assert delete + self.assertDictEqual(response_unpack, {"Effect": True}, + msg="Failed to delete sale" + ) + + # Assert sale is discarded + self.assertEqual(resdel_sale.status_code, 404, + msg="Failed to remove deleted item from records" + ) + + def test_delete_for_missing_resource(self): + guessed_path = self.path + '/' + str(randint(2345, 998888)) + response = self.app.get(guessed_path, + headers=self.headers, + follow_redirects=True + ) + + self.assertFalse(response.status_code == 200, + msg="Deletes a nonexistent sale" + ) + + +if __name__ == '__main__': + unittest.main()