diff --git a/.gitignore b/.gitignore index e2d16ff..1ce024a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ *__pycache__ venv .vscode +*.pytest_cache +instance + diff --git a/app/__init__.py b/app/__init__.py index 80afb5b..d92f812 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,8 +1,12 @@ from flask import Flask, Blueprint +from instance.config import DevConfig from .api.v1 import version1 +from .auth.v1 import auth def create_app(): app = Flask(__name__) + app.config.from_object(DevConfig) app.register_blueprint(version1) + app.register_blueprint(auth) return app \ No newline at end of file diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py index af06015..deb2096 100644 --- a/app/api/v1/__init__.py +++ b/app/api/v1/__init__.py @@ -1,7 +1,11 @@ from flask import Blueprint from flask_restful import Api - +from .views import ParcelList, IndividualParcel, CancelParcel version1 = Blueprint('v1', __name__, url_prefix="/api/v1") api = Api(version1) + +api.add_resource(ParcelList, '/parcels') +api.add_resource(IndividualParcel, '/parcels/') +api.add_resource(CancelParcel, '/parcels//cancel') diff --git a/app/api/v1/models.py b/app/api/v1/models.py index e69de29..f863b58 100644 --- a/app/api/v1/models.py +++ b/app/api/v1/models.py @@ -0,0 +1,46 @@ + +parcels = [] + +class Parcels: + """ + Class with CRUD functionalities on the Parcels resource + """ + def __init__(self): + self.db = parcels + self.parcel_status = 'pending' + + def create_order(self, item, pickup, dest, pricing): + """ + instance method to generate new entry into delivery orders list + """ + payload = { + "id" : len(self.db) + 1, + "itemName" : item, + "pickupLocation" : pickup, + "destination" : dest, + "pricing" : pricing, + "status" : self.parcel_status + } + self.db.append(payload) + + def order_list(self): + """ + retrieves entire list of delivery orders + """ + return self.db + + def retrieve_single_order(self, parcelID): + """ + retrieve a single order by id + """ + order_by_id = [parc for parc in self.db if parc['id'] == parcelID][0] + return order_by_id + + def cancel_order(self, ParcelID): + """ + update parcel status to cancel + """ + parcel_to_cancel = [parc for parc in self.db if parc['id'] == ParcelID] + parcel_to_cancel[0]['status'] = 'cancelled' + return parcel_to_cancel + \ No newline at end of file diff --git a/app/api/v1/views.py b/app/api/v1/views.py index 2842140..e50a6c5 100644 --- a/app/api/v1/views.py +++ b/app/api/v1/views.py @@ -1,19 +1,63 @@ from flask_restful import Resource from flask import make_response, jsonify, request +from .models import Parcels +order = Parcels() class ParcelList(Resource): - + """ + class for Create order and retrieve list of orders API endpoints + """ def post(self): - pass + """ + post method to add new order to list of orders + """ + data = request.get_json() + item = data['item'] + pickup = data['pickup'] + dest = data['dest'] + pricing = data['pricing'] + order.create_order(item, pickup, dest, pricing) + return make_response(jsonify({ + "message" : "delivery order created successfully" + }), 201) + def get(self): - pass + """ + get method to retrieve list of all orders + """ + resp = order.order_list() + return make_response(jsonify({ + "message" : "ok", + "Delivery Orders" : resp + }), 200) class IndividualParcel(Resource): - + """ + class for API endpoints for retrieving single order and cancelling particular order + """ def get(self, id): - pass + """ + get method to retrieve order by id + """ + single = order.retrieve_single_order(id) + return make_response(jsonify({ + "message" : "Ok", + "order" : single + }), 200) +class CancelParcel(Resource): + """ + class for endpoint to cancel parcel order + """ def put(self, id): - pass \ No newline at end of file + """ + PUT request to update parcel status to 'cancelled' + """ + cancel_parcel = order.cancel_order(id) + return make_response(jsonify({ + "message" : "order is cancelled", + "cancelled order" : cancel_parcel + }), 201) + \ No newline at end of file diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/auth/v1/__init__.py b/app/auth/v1/__init__.py new file mode 100644 index 0000000..6b41930 --- /dev/null +++ b/app/auth/v1/__init__.py @@ -0,0 +1,10 @@ +from flask import Blueprint +from flask_restful import Api +from .views import Registration, SignIn + +auth = Blueprint('auth', __name__, url_prefix="/auth/v1") + +api = Api(auth) + +api.add_resource(Registration, '/register') +api.add_resource(SignIn, '/login') diff --git a/app/auth/v1/models.py b/app/auth/v1/models.py new file mode 100644 index 0000000..1dc1f6b --- /dev/null +++ b/app/auth/v1/models.py @@ -0,0 +1,111 @@ +from datetime import datetime, timedelta +from flask import current_app +import jwt +from werkzeug.security import generate_password_hash, check_password_hash + +class MockDb(): + """ + class for a data structure database + """ + def __init__(self): + self.users = {} + self.orders = {} + self.user_no = 0 + self.entry_no = 0 + def drop(self): + self.__init__() + +db = MockDb() + +class Parent(): + """ + user class will inherit this class + """ + def update(self, data): + # Validate the contents before passing to mock database + for key in data: + setattr(self, key, data[key]) + setattr(self, 'last_updated', datetime.utcnow().isoformat()) + return self.lookup() + +class User(Parent): + """ + class to register user and generate tokens + """ + def __init__(self, email, password): + self.email = email + self.password = generate_password_hash(password) + self.id = None + self.created_at = datetime.utcnow().isoformat() + self.last_updated = datetime.utcnow().isoformat() + + def add_user(self): + """ + method to save a user's registration details + """ + setattr(self, 'id', db.user_no + 1) + db.users.update({self.id: self}) + db.user_no += 1 + db.orders.update({self.id: {}}) + return self.lookup() + + def validate_password(self, password): + """ + method to validate user password + """ + if check_password_hash(self.password, password): + return True + return False + + def lookup(self): + """ + method to jsonify object that represents user + """ + keys = ['email', 'id'] + return {key: getattr(self, key) for key in keys} + + def generate_token(self, userID): + """ + method that generates token during each login + """ + try: + payload = { + 'exp' : datetime.utcnow()+timedelta(minutes=5), + 'iat' : datetime.utcnow(), + 'id' : userID + } + token = jwt.encode( + payload, + current_app.config.get('SECRET_KEY'), + algorithm='HS256' + ) + return token + except Exception as err: + return str(err) + + @staticmethod + def decode_token(token): + """ + method to decode the token generated during login + """ + try: + #attempt to decode token using SECRET_KEY variable + payload = jwt.decode(token, current_app.config.get('SECRET_KEY')) + return payload['id'] + except jwt.ExpiredSignatureError: + # expired token returns an error string + return "Token expired. please login again to generate fresh token" + except jwt.InvalidTokenError: + #the token is not valid, throw error + return "Unworthy token. Please login to get fresh authorization" + + @classmethod + def get_user_by_email(cls, email): + """ + method for getting a user by email + """ + for user_id in db.users: + user = db.users.get(user_id) + if user.email == email: + return user + return None \ No newline at end of file diff --git a/app/auth/v1/views.py b/app/auth/v1/views.py new file mode 100644 index 0000000..3a7fccb --- /dev/null +++ b/app/auth/v1/views.py @@ -0,0 +1,56 @@ +from flask_restful import Resource +from flask import make_response,jsonify, request +from .models import User + +class Registration(Resource): + """ + class that handles registration of new user + """ + def post(self): + data = request.get_json() + email = data['email'] + password = data['password'] + + if not User.get_user_by_email(email): + new_user = User(email=email, password=password) + new_user.add_user() + + return make_response(jsonify({ + 'message' : 'you have successfully registered an account' + }), 201) + else: + response = { + 'message': 'Account with provided email exists. please login' + } + + return make_response((jsonify(response)), 202) + +class SignIn(Resource): + """ + class that handles logging into user accounts and token generation + """ + def post(self): + data = request.get_json() + email = data['email'] + password = data['password'] + try: + user = User.get_user_by_email(email) + user_id = user.id + if user and user.validate_password(password): + auth_token = user.generate_token(user_id) + if auth_token: + response = { + 'message' : 'Successfully logged in', + 'authentication token' : auth_token.decode() + } + return make_response(jsonify(response), 200) + else: + response = { + 'message' : 'User with email already exists, please login' + } + return make_response(jsonify(response), 401) + except Exception as err: + response = { + 'message' : str(err) + } + return make_response(jsonify(response), 500) \ 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/test_login.py b/app/tests/v1/test_login.py new file mode 100644 index 0000000..3ee73e4 --- /dev/null +++ b/app/tests/v1/test_login.py @@ -0,0 +1,40 @@ +from ... import create_app +import unittest +import json + +class LoginTestCase(unittest.TestCase): + """ + test class for the registration endpoint + """ + def setUp(self): + create_app().testing = True + self.app = create_app().test_client() + self.mock_data = { + 'email' : 'test@chocoly.com', + 'password' : 'balerion' + } + self.not_user_data = { + 'email' : 'not_user@chocoly.com', + 'password' : 'silmarillion' + } + + def test_user_signin(self): + #test if a registered user can log in + res = self.app.post('/auth/v1/register', data=json.dumps(self.mock_data), content_type='application/json') + self.assertEqual(res.status_code, 201) + signin_res = self.app.post('/auth/v1/login', data=json.dumps(self.mock_data), content_type='application/json') + result = json.loads(signin_res.data) + self.assertEqual(result['message'], "Successfully logged in") + self.assertEqual(signin_res.status_code, 200) + self.assertTrue(result['authentication token']) + + def test_non_registered_user(self): + #test that unregistered user cannot log in + res = self.app.post('/auth/v1/login', data=json.dumps(self.mock_data), content_type='application/json') + result = json.loads(res.data) + self.assertEqual(res.status_code, 500) + + +if __name__ == "__main__": + unittest.main() + \ No newline at end of file diff --git a/app/tests/v1/test_parcels.py b/app/tests/v1/test_parcels.py new file mode 100644 index 0000000..b408742 --- /dev/null +++ b/app/tests/v1/test_parcels.py @@ -0,0 +1,65 @@ +from ... import create_app +import unittest +import json + +class TestPracelCreation(unittest.TestCase): + """ + class for Parcels test case + """ + def setUp(self): + """ + Initialize app and define test variables + """ + create_app().testing = True + self.app = create_app().test_client() + self.data = { + "item" : "seven ballons", + "pickup" : "Biashara street", + "dest" : "Kikuyu town", + "pricing": "250 ksh" + } + + def test_POST_create_delivery_order(self): + """ + Test whether API can create a new delivery order via POSt request + """ + response = self.app.post('/api/v1/parcels', data=json.dumps(self.data), content_type='application/json') + result = json.loads(response.data) + self.assertEqual(response.status_code, 201) + self.assertIn('delivery order created', str(result)) + + def test_GET_delivery_orders_list(self): + """ + Test if API can retrieve a list of delivery orders + """ + response = self.app.post('/api/v1/parcels', data=json.dumps(self.data), content_type='application/json') + self.assertEqual(response.status_code, 201) + response = self.app.get('/api/v1/parcels', content_type='application/json') + self.assertEqual(response.status_code, 200) + result = json.loads(response.data) + self.assertIn('seven ballons', str(result)) + + def test_GET_single_delivery_order(self): + """ + Test if API can retrieve a single delivery order by its id + """ + response = self.app.post('/api/v1/parcels', data=json.dumps(self.data), content_type='application/json') + self.assertEqual(response.status_code, 201) + result = self.app.get('/api/v1/parcels/1') + self.assertEqual(result.status_code, 200) + self.assertIn('seven ballons', str(result.data)) + + def test_PUT_cancel_delivery_order(self): + """ + Test if API can cancel order by changing order status + """ + response = self.app.post('/api/v1/parcels', data=json.dumps(self.data), content_type='application/json') + self.assertEqual(response.status_code, 201) + result = self.app.put('/api/v1/parcels/1/cancel') + self.assertEqual(result.status_code, 201) + self.assertIn('order is cancelled', str(result.data)) + +# make the tests you have written executable + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/app/tests/v1/test_register.py b/app/tests/v1/test_register.py new file mode 100644 index 0000000..9543e51 --- /dev/null +++ b/app/tests/v1/test_register.py @@ -0,0 +1,37 @@ +from ... import create_app +import unittest +import json + +class AuthTestCase(unittest.TestCase): + """ + test class for the registration endpoint + """ + def setUp(self): + create_app().testing = True + self.app = create_app().test_client() + self.mock_data = { + 'email' : 'test@hotmail.com', + 'password' : 'holy_water' + } + self.mock_data2 = { + 'email' : 'qarth@hotmail.com', + 'password' : 'jade_sea' + } + + """ + def test_signup(self): + response2 = self.app.post('/auth/v1/register', data=json.dumps(self.mock_data), content_type='application/json') + res = json.loads(response2.data) + self.assertEqual(res['message'], "you have successfully registered an account") + """ + + def test_if_registered(self): + response1 = self.app.post('/auth/v1/register', data=json.dumps(self.mock_data2), content_type='application/json') + self.assertEqual(response1.status_code, 201) + duplicate_signup = self.app.post('/auth/v1/register', data=json.dumps(self.mock_data2), content_type='application/json') + self.assertEqual(duplicate_signup.status_code, 202) + res = json.loads(duplicate_signup.data) + self.assertEqual(res['message'], 'Account with provided email exists. please login') + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e69de29..2c457bb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -0,0 +1,25 @@ +aniso8601==4.0.1 +astroid==2.0.4 +atomicwrites==1.2.1 +attrs==18.2.0 +Click==7.0 +Flask==1.0.2 +Flask-RESTful==0.3.6 +isort==4.3.4 +itsdangerous==1.1.0 +Jinja2==2.10 +lazy-object-proxy==1.3.1 +MarkupSafe==1.0 +mccabe==0.6.1 +more-itertools==4.3.0 +pkg-resources==0.0.0 +pluggy==0.8.0 +py==1.7.0 +PyJWT==1.6.4 +pylint==2.1.1 +pytest==3.10.0 +pytz==2018.7 +six==1.11.0 +typed-ast==1.1.0 +Werkzeug==0.14.1 +wrapt==1.10.11