diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..4afc19f --- /dev/null +++ b/.coverage @@ -0,0 +1 @@ +!coverage.py: This is a private format, don't read it directly!{"lines": {"/Users/andela/Documents/projects/flaskapps/bucketlist-api/app/views.py": [1, 2, 3, 4, 6, 8, 9, 10, 11, 12, 13, 15, 16, 18, 21, 23], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/test.py": [1, 3, 4], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/app/resource.py": [1, 2, 3, 4, 5, 7, 9, 16, 17, 18, 20, 22, 28, 30, 31, 42, 43, 44, 45, 47, 49, 60, 61, 62, 63, 65, 66, 68, 71, 73, 86, 87, 88, 89, 90, 92, 94, 97, 100, 102, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 129, 132, 134, 147, 148, 149, 150, 153, 154, 155, 157, 160, 162, 173, 174, 175, 178, 179, 182, 184, 195, 196, 197, 198, 200, 201, 204, 207, 209, 223, 224, 225, 226, 227, 229, 230, 233, 234, 235, 237, 240, 243, 245, 259, 260, 261, 264, 265, 266, 267, 268, 269, 270, 271, 272, 273, 275, 276, 277, 280, 283, 285, 298, 299, 300, 301, 302, 304, 305, 308, 312, 313, 324, 325, 327, 328, 330, 333, 335, 348, 349, 350, 351, 353], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/app/helper.py": [1, 2, 3, 4, 6, 8, 10, 11, 12, 13, 14, 15, 17, 19, 20, 21, 22, 23, 24, 27, 31, 32, 33, 34, 35, 37, 42, 43, 44, 45, 47, 49, 53, 55, 60, 61, 62, 63, 64, 66, 71, 72, 73, 74, 75], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/test_config.py": [1, 2, 4, 6], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/app/models.py": [1, 2, 3, 6, 7, 8, 9, 10, 11, 12, 14, 15, 16, 18, 19, 20, 21, 22, 23, 24, 27, 28, 31, 32, 33, 34, 35, 36, 37, 38, 40, 41, 42, 44, 45, 46, 47, 48, 49, 50, 51, 54, 55, 58, 59, 60, 61, 62, 63, 64, 66, 67, 68, 69, 71, 72, 74, 75, 76, 77, 78, 79, 80, 83, 84, 85], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/production_config.py": [1, 2, 4, 6]}} \ No newline at end of file diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..0a3814c --- /dev/null +++ b/.coveragerc @@ -0,0 +1,6 @@ +[report] +omit = + */python?.?/* + tests/* + */site-packages/nose/* + *__init__* diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64fb82a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +*/*.pyc diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5273fdf --- /dev/null +++ b/.travis.yml @@ -0,0 +1,10 @@ +language: python +python: + - "2.7" +# command to install dependencies +install: + - pip install -r requirements.txt +script: + - nosetests --with-coverage +after_success: + - coveralls \ No newline at end of file diff --git a/README.md b/README.md index 6cde440..52f6b6f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,146 @@ -# bucketlist-api +# bucketlist API +[![Build Status](https://travis-ci.org/andela-snwuguru/bucketlist-api.svg?branch=ch-ci-integration)](https://travis-ci.org/andela-snwuguru/bucketlist-api) [![Coverage Status](https://coveralls.io/repos/github/andela-snwuguru/bucketlist-api/badge.svg?branch=ch-ci-integration)](https://coveralls.io/github/andela-snwuguru/bucketlist-api?branch=ch-ci-integration) + According to Merriam-Webster Dictionary, a Bucket List is a list of things that one has not done before but wants to do before dying. +This is a checkpoint 2 project used to evaluate Python beginner + +## Features, Endpoints and Accessiblity + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Features Endpoint Public
Register POST /auth/register True
AuthenticationPOST /auth/loginTrue
Create BucketlistPOST /bucketlists/ False
Fetch BucketlistsGET /bucketlists/ False
Fetch Single BucketlistsGET /bucketlists/:id False
Update bucketlist recordPUT /bucketlists/:id False
Delete bucketlist recordDELETE /bucketlists/:id False
Create Bucketlist ItemPOST /bucketlists/:id/items False
Fetch Bucketlists ItemsGET /bucketlists/:id/items False
Fetch Single Bucketlists itemGET /bucketlists/:id/items/:itemId False
Update bucketlist item recordPUT /bucketlists/:id/items/:itemId False
Delete bucketlist item recordDELETE /bucketlists/:id/items/:itemId False
+ +## Dependecies +All dependecies can be found in requirements.txt + +## How to use +- Clone project git clone `` git@github.com:andela-snwuguru/bucketlist-api.git `` +- Create a virtual environment `` mkvirtualenv bucketlist `` +- Install dependecies `` pip install -r requirements.txt `` +- Navigate to project folder `` cd ~/bucketlist-api `` +- Run migrationscript +``` + python script.py db migrate + python script.py db upgrade +``` +- Run Project `` python run.py `` + +## Sample Request + +#### Register new User +``` +Request +-------- +http POST http://127.0.0.1:5000/api/v1.0/auth/register username=guru password=test email=guru@mail.com + +Response +-------- +{ + "data": { + "date_created": "2016-05-19 19:37:20", + "date_modified": "2016-05-19 19:37:20", + "email": "guru@mail.com", + "id": 7, + "username": "guru" + } +} +``` + +#### Retrieve Access Token +``` +Request +-------- +http POST http://127.0.0.1:5000/api/v1.0/auth/login username=guru password=test + +Response +-------- +{ + "data": { + "date_created": "2016-05-19 19:37:20", + "date_modified": "2016-05-19 19:37:20", + "email": "guru@mail.com", + "id": 7, + "username": "guru" + }, + "token": "098f6bcd4621d373cade4e832627b4f6|7|.Ch-n_A._bH9Hx_kpibiIlRHvFRZbVt-6UM" +} + +``` + +### Documentation + +Api documentation is still in progress, it Will soon be available. \ No newline at end of file diff --git a/app.db b/app.db new file mode 100644 index 0000000..dbaacc2 Binary files /dev/null and b/app.db differ diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e815af4 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,11 @@ +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from flask import request + +app = Flask(__name__) + +#server = request.args.get('server','production').lower() +app.config.from_object('production_config') +db = SQLAlchemy(app) + +from app import views \ No newline at end of file diff --git a/app/helper.py b/app/helper.py new file mode 100644 index 0000000..d10d548 --- /dev/null +++ b/app/helper.py @@ -0,0 +1,75 @@ +from flask_restful import reqparse +import hashlib +from itsdangerous import TimestampSigner +from app import db + +encrypt_key = 'bucketlists api' + +def save(model): + """ Save a row in the database """ + try: + db.session.add(model) + db.session.commit() + return True + except: + return False + +def delete(model): + """ Deletes a row from the database """ + try: + db.session.delete(model) + db.session.commit() + return True + except: + return False + + +def get_user_id_from_token(token): + """ + This method extracts the user id from provided access token + """ + data = token.split('|') + try: + return data[1] + except: + return 0 + +def validate_args(fields={}): + """ + This method helps to parse and validate provided parameters. + It will return parsed argument if the require fields are in request + """ + parser = reqparse.RequestParser() + for field in fields.keys(): + help_message = field + ' can not be blank' + parser.add_argument(field, required=fields[field], help=help_message) + + return parser.parse_args() + +def md5(string): + """ + This method will return md5 hash of a given string + """ + return hashlib.md5(string.encode("utf")).hexdigest() + +def encrypt(string): + """ + This method will return encrypted version of a string. + It will return False if encryption fails + """ + try: + signer = TimestampSigner(encrypt_key) + return signer.sign(string) + except: + return False + +def decrypt(string, max_age=15000): + """ + This method will return decrypted version of an encrypted string. + If age of encryption is greater than max age it will return False + """ + try: + signer = TimestampSigner(encrypt_key) + return signer.unsign(string, max_age=max_age) + except: + return False diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..ef8cac3 --- /dev/null +++ b/app/models.py @@ -0,0 +1,85 @@ +from app import db +from datetime import datetime +from app.helper import encrypt, decrypt + + +class BucketListModel(db.Model): + id = db.Column(db.Integer(), primary_key=True) + name = db.Column(db.String(255), unique=True) + created_by = db.Column(db.Integer, db.ForeignKey('user.id')) + date_created = db.Column(db.DateTime, default=db.func.current_timestamp()) + date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp()) + user = db.relationship('User', backref=db.backref('bucketlists', lazy='dynamic')) + + def __init__(self, name, user): + self.name = name + self.user = user + + def get(self): + return { + 'id':self.id, + 'name':self.name, + 'created_by':self.user.username, + 'date_created':str(self.date_created), + 'date_modified':str(self.date_modified) + } + + def __repr__(self): + return '' % self.name + + +class BucketListItemModel(db.Model): + id = db.Column(db.Integer(), primary_key=True) + task = db.Column(db.String(255)) + done = db.Column(db.Boolean(), default=False) + date_created = db.Column(db.DateTime, default=db.func.current_timestamp()) + date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp()) + bucketlist_id = db.Column(db.Integer, db.ForeignKey('bucket_list_model.id')) + bucketlist = db.relationship('BucketListModel', backref=db.backref('items', lazy='dynamic')) + + def __init__(self, task, bucketlist): + self.task = task + self.bucketlist = bucketlist + + def get(self): + return { + 'id':self.id, + 'task':self.task, + 'done':self.done, + 'bucketlists':self.bucketlist.name, + 'date_created':str(self.date_created), + 'date_modified':str(self.date_modified) + } + + def __repr__(self): + return '' % self.task + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(100), unique=True) + password = db.Column(db.String(20)) + email = db.Column(db.String(200), unique=True) + date_created = db.Column(db.DateTime, default=db.func.current_timestamp()) + date_modified = db.Column(db.DateTime, default=db.func.current_timestamp(), onupdate=db.func.current_timestamp()) + + def __init__(self, username, email, password): + self.username = username + self.email = email + self.password = password + + def __repr__(self): + return '' % self.username + + def get(self): + return { + 'id':self.id, + 'username':self.username, + 'email':self.email, + 'date_created':str(self.date_created), + 'date_modified':str(self.date_modified), + } + + def generate_token(self): + string = str(self.password) + '|' + str(self.id) + '|' + return encrypt(string) diff --git a/app/resource.py b/app/resource.py new file mode 100644 index 0000000..624f124 --- /dev/null +++ b/app/resource.py @@ -0,0 +1,357 @@ +from flask_restful import reqparse, abort, Resource +from flask_httpauth import HTTPBasicAuth +from flask import request, make_response, jsonify +from app.models import * +from app.helper import * + +auth = HTTPBasicAuth() + +@auth.verify_password +def verify_token(username, password): + """ + This method will verify the token and allow access to any resource that requires authentication + """ + + global token + token = request.headers.get('AccessToken','') + if not token: + return False + + return decrypt(token) + +@auth.error_handler +def unauthorized(): + """ + This will return a JSON error 403 response when token validation fails + """ + + return make_response(jsonify({'error': 'Unauthorized access','code':403}), 403) + +class BucketList(Resource): + @auth.login_required + def get(self, id): + """ + This endpoint returns bucketlists details of a given bucketlist id. + Method: GET + Header: + AccessToken (required) + + Response: JSON + """ + + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + return {'data':bucketlist.get()} + + @auth.login_required + def delete(self, id): + """ + This endpoint deletes a given bucketlist id from the database. + Method: DELETE + Header: + AccessToken (required) + + Response: JSON + """ + + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + if len(bucketlist.items.all()): + abort(400, message="This Item is associated with bucketlist items") + + if not delete(bucketlist): + abort(401, message="Unable to delete record") + + return {}, 204 + + @auth.login_required + def put(self, id): + """ + This endpoint returns updated bucketlist details of a given bucketlist id. + Method: PUT + Parameters: + name (required) + Header: + AccessToken (required) + + Response: JSON + """ + + args = validate_args({'name':True}) + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + bucketlist.name = args['name'] + + if not save(bucketlist): + abort(409, message="Item already exists") + + return {'data':bucketlist.get()}, 201 + + +class BucketLists(Resource): + + @auth.login_required + def get(self): + """ + This endpoint returns list of bucketlists based on access token. + Method: GET + Parameters: + limit (optional) default=25 + page (optional) default=1 + Header: + AccessToken (required) + + Response: JSON + """ + + limit = int(request.args.get('limit',25)) + page = int(request.args.get('page',1)) + q = request.args.get('q','') + offset = (page * limit) - limit + user_id = get_user_id_from_token(token) + next_page = page + 1 + prev_page = page - 1 if page > 1 else 1 + all_list = BucketListModel.query.filter(BucketListModel.name.like("%" + q + "%"), BucketListModel.created_by.is_(user_id)).all() + bucketlists = all_list[offset:(limit + offset)] + result = {'data':[bucketlist.get() for bucketlist in bucketlists],'page':{}} + if (limit + offset) < len(all_list): + result['page']['next'] = "/api/v1.0/bucketlists?limit=" + str(limit) + "&page=" + str(next_page) + + if offset: + result['page']['prev'] = "/api/v1.0/bucketlists?limit=" + str(limit) + "&page=" + str(prev_page) + + return result + + @auth.login_required + def post(self): + """ + This endpoint returns created bucketlist details. + Method: POST + Parameters: + name (required) + Header: + AccessToken (required) + + Response: JSON + """ + + args = validate_args({'name':True}) + user_id = get_user_id_from_token(token) + user = User.query.filter_by(id=user_id).first() + if not user: + abort(403, message="Unauthorized access") + + bucketlist = BucketListModel(args['name'], user) + if not save(bucketlist): + abort(409, message="Item already exists") + + return {'data': bucketlist.get()}, 201 + + +class BucketListItem(Resource): + + @auth.login_required + def get(self, id, item_id): + """ + This endpoint returns bucketlists item details of a given bucketlist item id. + Method: GET + Header: + AccessToken (required) + + Response: JSON + """ + + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + item = BucketListItemModel.query.filter_by(bucketlist_id=bucketlist.id, id=int(item_id)).first() + if not item: + abort(400, message="Item does not exist") + + return {'data':item.get()}, 200 + + @auth.login_required + def delete(self, id, item_id): + """ + This endpoint deletes a given bucketlist item id from the database. + Method: DELETE + Header: + AccessToken (required) + + Response: JSON + """ + + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + item = BucketListItemModel.query.filter_by(bucketlist_id=bucketlist.id, id=int(item_id)).first() + if not item: + abort(400, message="Item does not exist") + + if not delete(item): + abort(401, message="Unable to delete record") + + return {}, 204 + + @auth.login_required + def put(self, id, item_id): + """ + This endpoint returns updated bucketlist item details of a given bucketlist item id. + Method: PUT + Parameters: + task (optional) + done (optional) + Header: + AccessToken (required) + + Response: JSON + """ + + args = validate_args({'task':False,'done':False}) + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + item = BucketListItemModel.query.filter_by(bucketlist_id=bucketlist.id, id=int(item_id)).first() + if not item: + abort(400, message="Item does not exist") + + item.task = args.get('task',item.task) + done = args.get('done','false') + item.done = True if done.lower() == 'true' else False + + if not save(item): + abort(409, message="Unable to update record") + + return {'data':item.get()}, 201 + + +class BucketListItems(Resource): + + @auth.login_required + def get(self, id): + """ + This endpoint returns list of bucketlist items based on access token and bucketlist id. + Method: GET + Parameters: + limit (optional) default=25 + page (optional) default=1 + Header: + AccessToken (required) + + Response: JSON + """ + + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + limit = int(request.args.get('limit',25)) + page = int(request.args.get('page',1)) + q = request.args.get('q','') + offset = (page * limit) - limit + user_id = get_user_id_from_token(token) + next_page = page + 1 + prev_page = page - 1 if page > 1 else 1 + all_items = BucketListItemModel.query.filter( + BucketListItemModel.task.like("%" + q + "%"), + BucketListItemModel.bucketlist_id.is_(bucketlist.id) + ).all() + items = all_items[offset:(limit + offset)] + result = {'data':[item.get() for item in items],'page':{}} + if (limit + offset) < len(all_items): + result['page']['next'] = "/api/v1.0/bucketlists/" + str(id) + "?limit=" + str(limit) + "&page=" + str(next_page) + + if offset: + result['page']['prev'] = "/api/v1.0/bucketlists" + str(id) + "?limit=" + str(limit) + "&page=" + str(prev_page) + + return result + + @auth.login_required + def post(self, id): + """ + This endpoint returns created bucketlist item details. + Method: POST + Parameters: + task (required) + Header: + AccessToken (required) + + Response: JSON + """ + + args = validate_args({'task':True}) + user_id = get_user_id_from_token(token) + bucketlist = BucketListModel.query.filter_by(id=id, created_by=int(user_id)).first() + if not bucketlist: + abort(403, message="Unauthorized access") + + item = BucketListItemModel(args['task'], bucketlist) + if not save(item): + abort(409, message="Item already exists") + + return {'data': item.get()}, 201 + + + +class Login(Resource): + def post(self): + """ + This will authenticate user and provide token used to access other resources + + Method: POST + Parameters: + username (required) + password (required) + Response: JSON + """ + + args = validate_args({'username':True, 'password':True}) + user = User.query.filter_by(username=args['username'],password=md5(args['password'])).first() + + if not user: + abort(403, message='Invalid user credentials') + + return {'token': user.generate_token(),'data': user.get()}, 200 + + +class Register(Resource): + + def post(self): + """ + This will register a new user and returns the user credentials + + Method: POST + Parameters: + username (required) + email (required) + password (optional) default='' + + Response: JSON + """ + + args = validate_args({'username':True, 'password':True, 'email':True}) + user = User(args['username'], args['email'], md5(args['password'])) + if not save(user): + abort(401, message="User already exists") + + return {'data': user.get()}, 201 + + + + diff --git a/app/views.py b/app/views.py new file mode 100644 index 0000000..13b526e --- /dev/null +++ b/app/views.py @@ -0,0 +1,23 @@ +from app import app +from flask import jsonify, abort, make_response, request, url_for +from flask_restful import Api +from app.resource import * + +api = Api(app, prefix='/api/v1.0') + +api.add_resource(Login, '/auth/login') +api.add_resource(Register, '/auth/register') +api.add_resource(BucketLists, '/bucketlists') +api.add_resource(BucketList, '/bucketlists/') +api.add_resource(BucketListItems, '/bucketlists//items') +api.add_resource(BucketListItem, '/bucketlists//items/') + +@app.route('/') +@app.route('/index') +def index(): + return "Hello, BucketList!" + + +@app.errorhandler(404) +def not_found(error): + return make_response(jsonify({'error': 'Resource not found ','code':404}), 404) diff --git a/migrations/README b/migrations/README new file mode 100755 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100755 index 0000000..4593816 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,87 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +from flask import current_app +config.set_main_option('sqlalchemy.url', + current_app.config.get('SQLALCHEMY_DATABASE_URI')) +target_metadata = current_app.extensions['migrate'].db.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.readthedocs.org/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100755 index 0000000..9570201 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/02e15b7e0013_.py b/migrations/versions/02e15b7e0013_.py new file mode 100644 index 0000000..09ed1c5 --- /dev/null +++ b/migrations/versions/02e15b7e0013_.py @@ -0,0 +1,28 @@ +"""empty message + +Revision ID: 02e15b7e0013 +Revises: d26feb2b3fde +Create Date: 2016-05-11 12:11:47.981244 + +""" + +# revision identifiers, used by Alembic. +revision = '02e15b7e0013' +down_revision = 'd26feb2b3fde' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('date_created', sa.DateTime(), nullable=True)) + op.add_column('user', sa.Column('date_modified', sa.DateTime(), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'date_modified') + op.drop_column('user', 'date_created') + ### end Alembic commands ### diff --git a/migrations/versions/ac8bcafddd94_.py b/migrations/versions/ac8bcafddd94_.py new file mode 100644 index 0000000..13cdd44 --- /dev/null +++ b/migrations/versions/ac8bcafddd94_.py @@ -0,0 +1,58 @@ +"""empty message + +Revision ID: ac8bcafddd94 +Revises: f7600f862e03 +Create Date: 2016-05-17 22:13:38.480965 + +""" + +# revision identifiers, used by Alembic. +revision = 'ac8bcafddd94' +down_revision = 'f7600f862e03' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('password', sa.String(length=20), nullable=True), + sa.Column('email', sa.String(length=200), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_table('bucket_list_model', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('bucket_list_item_model', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task', sa.String(length=255), nullable=True), + sa.Column('done', sa.Boolean(), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.Column('bucketlist_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['bucketlist_id'], ['bucket_list_model.id'], ), + sa.PrimaryKeyConstraint('id') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('bucket_list_item_model') + op.drop_table('bucket_list_model') + op.drop_table('user') + ### end Alembic commands ### diff --git a/migrations/versions/d26feb2b3fde_.py b/migrations/versions/d26feb2b3fde_.py new file mode 100644 index 0000000..8f31bee --- /dev/null +++ b/migrations/versions/d26feb2b3fde_.py @@ -0,0 +1,55 @@ +"""empty message + +Revision ID: d26feb2b3fde +Revises: None +Create Date: 2016-05-11 09:23:43.012470 + +""" + +# revision identifiers, used by Alembic. +revision = 'd26feb2b3fde' +down_revision = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=100), nullable=True), + sa.Column('email', sa.String(length=200), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_table('bucket_list_model', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['created_by'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('bucket_list_item_model', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('task', sa.String(length=255), nullable=True), + sa.Column('done', sa.Boolean(), nullable=True), + sa.Column('date_created', sa.DateTime(), nullable=True), + sa.Column('date_modified', sa.DateTime(), nullable=True), + sa.Column('bucketlist_id', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['bucketlist_id'], ['bucket_list_model.id'], ), + sa.PrimaryKeyConstraint('id') + ) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_table('bucket_list_item_model') + op.drop_table('bucket_list_model') + op.drop_table('user') + ### end Alembic commands ### diff --git a/migrations/versions/f7600f862e03_.py b/migrations/versions/f7600f862e03_.py new file mode 100644 index 0000000..ebfa754 --- /dev/null +++ b/migrations/versions/f7600f862e03_.py @@ -0,0 +1,26 @@ +"""empty message + +Revision ID: f7600f862e03 +Revises: 02e15b7e0013 +Create Date: 2016-05-12 17:38:42.044363 + +""" + +# revision identifiers, used by Alembic. +revision = 'f7600f862e03' +down_revision = '02e15b7e0013' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('password', sa.String(length=20), nullable=True)) + ### end Alembic commands ### + + +def downgrade(): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'password') + ### end Alembic commands ### diff --git a/production_config.py b/production_config.py new file mode 100644 index 0000000..ca33db6 --- /dev/null +++ b/production_config.py @@ -0,0 +1,6 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'app.db') +#SQLALCHEMY_DATABASE_URI = 'postgresql://sunday:@localhost/bucketlist' +SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'migrations') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a8828e --- /dev/null +++ b/requirements.txt @@ -0,0 +1,23 @@ +alembic==0.8.6 +aniso8601==1.1.0 +coverage==4.0.3 +coveralls==1.1 +docopt==0.6.2 +Flask==0.10.1 +Flask-HTTPAuth==3.1.2 +Flask-Migrate==1.8.0 +Flask-RESTful==0.3.5 +Flask-Script==2.0.5 +Flask-SQLAlchemy==2.1 +itsdangerous==0.24 +Jinja2==2.8 +Mako==1.0.4 +MarkupSafe==0.23 +pycrypto==2.6.1 +python-dateutil==2.5.3 +python-editor==1.0 +pytz==2016.4 +requests==2.10.0 +six==1.10.0 +SQLAlchemy==1.0.12 +Werkzeug==0.11.9 diff --git a/run.py b/run.py new file mode 100644 index 0000000..1f0b926 --- /dev/null +++ b/run.py @@ -0,0 +1,3 @@ +from app import app + +app.run(debug=True) \ No newline at end of file diff --git a/script.py b/script.py new file mode 100755 index 0000000..95a6702 --- /dev/null +++ b/script.py @@ -0,0 +1,11 @@ +from app import app, db +from flask_script import Manager +from flask_migrate import Migrate, MigrateCommand +from app.models import * + +migrate = Migrate(app, db) +manager = Manager(app) +manager.add_command('db', MigrateCommand) + +if __name__ == '__main__': + manager.run() \ No newline at end of file diff --git a/test.db b/test.db new file mode 100644 index 0000000..6fec5c4 Binary files /dev/null and b/test.db differ diff --git a/test.py b/test.py new file mode 100644 index 0000000..9ea02e4 --- /dev/null +++ b/test.py @@ -0,0 +1,4 @@ +import unittest + +tests = unittest.TestLoader().discover('tests') +unittest.TextTestRunner().run(tests) \ No newline at end of file diff --git a/test_config.py b/test_config.py new file mode 100644 index 0000000..bfc8c3b --- /dev/null +++ b/test_config.py @@ -0,0 +1,6 @@ +import os +basedir = os.path.abspath(os.path.dirname(__file__)) + +SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'test.db') +#SQLALCHEMY_DATABASE_URI = 'postgresql://sunday:@localhost/bucketlist' +SQLALCHEMY_MIGRATE_REPO = os.path.join(basedir, 'migrations') \ No newline at end of file diff --git a/tests/.coverage b/tests/.coverage new file mode 100644 index 0000000..c2d1203 --- /dev/null +++ b/tests/.coverage @@ -0,0 +1 @@ +!coverage.py: This is a private format, don't read it directly!{"lines": {"/Library/Python/2.7/site-packages/nose/importer.py": [143, 144, 146, 147, 148, 149, 151, 152, 153, 154, 155, 156, 157, 158, 32, 161, 40, 41, 42, 44, 45, 47, 30, 54, 59, 62, 53, 65, 66, 67, 68, 70, 71, 72, 75, 76, 77, 78, 79, 80, 81, 94, 96, 97, 98, 63], "/Library/Python/2.7/site-packages/nose/loader.py": [128, 131, 179, 134, 135, 151, 143, 144, 145, 146, 147, 404, 149, 150, 89, 409, 154, 156, 90, 158, 159, 160, 161, 418, 91, 420, 421, 406, 424, 92, 170, 455, 178, 157, 177, 434, 435, 436, 181, 182, 183, 180, 186, 447, 416, 196, 197, 454, 481, 427, 201, 417, 79, 81, 82, 83, 84, 85, 86, 473, 474, 475, 476, 93, 94, 95, 97, 99, 422, 425, 209, 210, 369, 371, 410, 374, 375, 212, 378, 379, 200, 426, 405], "/Library/Python/2.7/site-packages/nose/plugins/isolate.py": [61, 62], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/tests/test_bucketlists.py": [1, 2, 3, 4, 5], "/Library/Python/2.7/site-packages/nose/pyversion.py": [70, 175], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/tests/test_bucketlists_items.py": [1, 2, 3, 4, 5], "/Library/Python/2.7/site-packages/nose/plugins/debug.py": [40, 41, 42, 43], "/Library/Python/2.7/site-packages/nose/suite.py": [539, 540, 543, 544, 545, 546, 547, 548, 551, 552, 554, 52, 53, 68, 72, 73, 75, 76, 79, 80, 81, 94, 95, 96, 97, 98, 99, 100, 103, 104, 105, 106, 107, 113, 114, 148, 149, 150, 151, 153, 154, 155, 156, 157, 158, 173, 177, 201, 204, 205, 208, 209, 216, 217, 218, 224, 226, 227, 228, 269, 270, 274, 277, 278, 279, 282, 283, 285, 286, 287, 288, 289, 290, 291, 292, 293, 297, 298, 301, 302, 303, 304, 308, 309, 310, 312, 313, 314, 315, 323, 324, 325, 326, 327, 328, 329, 330, 331, 337, 338, 339, 340, 341, 342, 345, 346, 347, 348, 349, 350, 351, 356, 357, 358, 360, 361, 362, 364, 365, 366, 367, 368, 372, 373, 374, 394, 396, 397, 401, 402, 403, 404, 405, 406, 407, 418, 419, 420, 421, 422, 423, 424, 427, 435, 436, 441, 443, 445, 446, 451, 452, 453, 454, 457, 459, 460, 462, 463, 464, 465, 466, 471, 474, 475, 476, 477, 478, 479, 480, 481, 482, 483, 484, 485, 486], "/Library/Python/2.7/site-packages/nose/plugins/doctests.py": [192, 193, 194, 195, 188, 189, 190, 191], "/Library/Python/2.7/site-packages/nose/plugins/failuredetail.py": [33, 35, 36], "/Library/Python/2.7/site-packages/nose/plugins/logcapture.py": [34, 38, 39, 40, 41, 44, 178, 179, 193, 194, 195, 196, 198, 199, 76, 204, 77, 78, 207, 80, 209, 86, 217, 79, 222, 208, 233, 234, 235, 240], "/Library/Python/2.7/site-packages/nose/result.py": [158, 159, 38, 39, 40, 41, 43, 44, 182, 183, 186, 187, 60, 61, 65, 69, 75, 76, 77, 81, 82, 88, 89, 90, 97, 98, 59, 103, 104, 105, 106, 109, 110], "/Library/Python/2.7/site-packages/nose/selector.py": [129, 130, 131, 134, 135, 35, 37, 40, 41, 42, 43, 44, 45, 53, 57, 244, 222, 224, 225, 226, 227, 228, 229, 232, 233, 234, 235, 236, 237, 238, 239, 240, 241, 116, 117, 118, 119, 123, 126, 127], "/Library/Python/2.7/site-packages/nose/plugins/deprecated.py": [40, 42, 43, 44], "/Library/Python/2.7/site-packages/nose/util.py": [320, 278, 263, 520, 521, 522, 267, 524, 526, 527, 272, 529, 530, 323, 660, 662, 663, 446, 312, 163, 164, 470, 321, 264, 306, 307, 308, 309, 310, 265, 184, 313, 266, 187, 188, 189, 190, 319, 192, 279, 322, 195, 471, 140, 311, 337, 338, 339, 340, 270, 342, 343, 271, 479, 481, 483, 484, 485, 486, 273, 449, 448, 318, 502, 503, 504, 276, 506, 447], "/Library/Python/2.7/site-packages/nose/plugins/prof.py": [71, 74, 75, 76, 80, 81, 82, 83, 84, 57], "/Library/Python/2.7/site-packages/nose/proxy.py": [128, 129, 130, 131, 132, 133, 30, 168, 169, 170, 43, 45, 46, 47, 176, 177, 178, 57, 58, 59, 60, 62, 64, 78, 80, 81, 82, 83, 89, 96, 102, 103, 104, 110, 111, 112, 116, 117, 118, 122, 123, 124, 125], "/Library/Python/2.7/site-packages/nose/plugins/testid.py": [137, 138, 142, 143, 144, 145, 148, 149, 150, 151, 154, 155], "/Library/Python/2.7/site-packages/nose/plugins/cover.py": [164, 263, 173, 271, 182, 183], "/Library/Python/2.7/site-packages/nose/plugins/skip.py": [57, 59, 60, 61], "/Library/Python/2.7/site-packages/nose/plugins/capture.py": [64, 96, 98, 69, 102, 97, 80, 74, 75, 76, 111, 112, 58, 59, 101], "/Library/Python/2.7/site-packages/nose/plugins/base.py": [98, 100, 101, 102], "/Library/Python/2.7/site-packages/nose/core.py": [65, 34, 36, 37, 41, 42, 43, 44, 50, 51, 55, 56, 59, 188, 61, 62, 193, 66, 199, 200, 201, 202, 203, 204, 205, 207, 187, 60], "/Library/Python/2.7/site-packages/nose/failure.py": [37, 38, 39, 22, 23, 24, 25, 26, 27, 30, 31], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/tests/test_edge_cases.py": [1, 2, 3, 4, 5], "/Library/Python/2.7/site-packages/nose/plugins/manager.py": [128, 262, 263, 264, 265, 138, 139, 140, 141, 142, 143, 272, 273, 274, 149, 167, 134, 166, 295, 168, 301, 302, 125, 177, 178, 137, 184, 249, 88, 89, 93, 94, 95, 96, 144, 99, 252, 106, 107, 111, 114, 123, 118, 105, 120, 121, 250, 251, 124, 253, 254], "/Users/andela/Documents/projects/flaskapps/bucketlist-api/app/__init__.py": [1], "/Library/Python/2.7/site-packages/nose/plugins/xunit.py": [192, 193, 191], "/Library/Python/2.7/site-packages/nose/plugins/errorclass.py": [140, 141, 143, 144, 148, 150, 151, 152, 153, 154], "/Library/Python/2.7/site-packages/nose/plugins/multiprocess.py": [224, 225, 226, 227, 231, 233, 234, 235, 238, 223], "/Library/Python/2.7/site-packages/nose/case.py": [128, 129, 130, 131, 132, 133, 51, 140, 173, 147, 148, 149, 151, 154, 155, 29, 159, 160, 33, 34, 163, 36, 37, 38, 39, 40, 41, 42, 172, 45, 48, 49, 178, 179, 185, 59, 60, 64, 69, 70, 161, 74, 162, 99, 100, 101, 102, 103, 104, 167, 168, 166]}} \ No newline at end of file diff --git a/tests/test_bucketlists.py b/tests/test_bucketlists.py new file mode 100644 index 0000000..9939b88 --- /dev/null +++ b/tests/test_bucketlists.py @@ -0,0 +1,140 @@ +import unittest +import json +from os import sys, path +sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +from app import app, db +from app.models import * +from app.helper import delete + + +class TestBucketlistResources(unittest.TestCase): + """Test cases for Resources""" + + def register(self): + if self.uid: + return + + user = {'username':'tuser', 'password':'test','email':'tuser@mail.com'} + response = self.app.post('/api/v1.0/auth/register',data=user) + self.assertEqual(response.status_code, 201) + result = json.loads(response.data) + if not result.get('message'): + self.uid = result['data']['id'] + self.token = self.get_token() + + + def tearDown(self): + """ Clean up database """ + self.remove_user() + self.remove_bucketlist() + + def get_token(self): + """ Login to retrieve access token """ + + user = {'username':'tuser', 'password':'test'} + response = self.app.post('/api/v1.0/auth/login',data=user) + result = json.loads(response.data) + return result.get('token','') + + def setUp(self): + app.config.from_object('test_config') + self.app = app.test_client() + self.uid = 0 + self.token = '' + self.bucketlist_id = 0 + self.bucketlist_item_id = 0 + + + def remove_user(self): + """ Remove user record """ + + user = User.query.filter_by(id=self.uid).first() + if user: + delete(user) + + def remove_bucketlist(self): + """ Remove bucketlist record """ + + bucketlist = BucketListModel.query.filter_by(id=self.bucketlist_id).first() + if bucketlist: + delete(bucketlist) + + + def new_bucketlist(self): + """ Post to bucketlists endpoint """ + + new_bucketlists = self.app.post('/api/v1.0/bucketlists', data={'name':'test item 1'}, + headers={'AccessToken':self.token}) + self.assertEqual(new_bucketlists.status_code, 201) + new_bucketlists_result = json.loads(new_bucketlists.data) + if new_bucketlists_result.get('data'): + self.bucketlist_id = new_bucketlists_result['data']['id'] + duplicate_bucketlists = self.app.post('/api/v1.0/bucketlists',data={'name':'test item 1'} , + headers={'AccessToken':self.token}) + self.assertEqual(duplicate_bucketlists.status_code, 409) + + def new_bucketlist_item(self): + # posting to bucketlists endpoint + new_bucketlist_item = self.app.post('/api/v1.0/bucketlists/' + str(self.bucketlist_id) + '/items', + data={'task':'buy a private jet'}, + headers={'AccessToken':self.token}) + + self.assertEqual(new_bucketlist_item.status_code, 201) + new_bucketlist_item_result = json.loads(new_bucketlist_item.data) + self.assertNotEqual(new_bucketlist_item_result.get('data'), None) + + def get_bucketlists(self): + """ Get bucketlists """ + + get_bucketlists = self.app.get('/api/v1.0/bucketlists', headers={'AccessToken':self.token}) + self.assertEqual(get_bucketlists.status_code, 200) + get_result = json.loads(get_bucketlists.data) + self.assertNotEqual(get_result.get('data'), None) + + def get_single_bucketlist(self): + """ Get bucketlists single item """ + + get_bucketlists = self.app.get('/api/v1.0/bucketlists/0', + headers={'AccessToken':self.token}) + self.assertEqual(get_bucketlists.status_code, 403) + + get_bucketlists = self.app.get('/api/v1.0/bucketlists/'+ str(self.bucketlist_id), + headers={'AccessToken':self.token}) + self.assertEqual(get_bucketlists.status_code, 200) + get_result = json.loads(get_bucketlists.data) + self.assertNotEqual(get_result.get('data'), None) + + def update_bucketist(self): + """ Update bucketlists """ + + put_bucketlists = self.app.put('/api/v1.0/bucketlists/0', + data={'name':'test item modified'}, headers={'AccessToken':self.token}) + self.assertEqual(put_bucketlists.status_code, 403) + + put_bucketlists = self.app.put('/api/v1.0/bucketlists/'+ str(self.bucketlist_id), + data={'name':'test item modified'}, headers={'AccessToken':self.token}) + self.assertEqual(put_bucketlists.status_code, 201) + put_result = json.loads(put_bucketlists.data) + data = put_result.get('data') + self.assertEqual(str(data['name']), 'test item modified') + + def delete_bucketlist(self): + """ Delete bucketlists """ + + del_bucketlists = self.app.delete('/api/v1.0/bucketlists/'+ str(self.bucketlist_id), + headers={'AccessToken':self.token}) + self.assertEqual(del_bucketlists.status_code, 204) + + def test_bucketlists_endpoint(self): + """ Test all endpoints of bucketlists """ + + self.register() + self.new_bucketlist() + self.get_bucketlists() + self.get_single_bucketlist() + self.update_bucketist() + self.delete_bucketlist() + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_bucketlists_items.py b/tests/test_bucketlists_items.py new file mode 100644 index 0000000..aeaf671 --- /dev/null +++ b/tests/test_bucketlists_items.py @@ -0,0 +1,180 @@ +import unittest +import json +from os import sys, path +sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +from app import app, db +from app.models import * +from app.helper import delete + + +class TestBucketlistItemResources(unittest.TestCase): + """Test cases for Bucketlist Items""" + + def register(self): + """ Register new user """ + if self.uid: + return + + user = {'username':'tuser', 'password':'test','email':'tuser@mail.com'} + response = self.app.post('/api/v1.0/auth/register',data=user) + self.assertEqual(response.status_code, 201) + + duplicate_response = self.app.post('/api/v1.0/auth/register',data=user) + self.assertEqual(duplicate_response.status_code, 401) + + result = json.loads(response.data) + if not result.get('message'): + self.uid = result['data']['id'] + self.token = self.get_token() + + + def tearDown(self): + """ Clean up the database """ + + self.remove_user() + self.remove_bucketlist() + self.remove_bucketlist_item() + + def get_token(self): + """ Login to retrieve access token """ + + user = {'username':'tuserfake', 'password':'test'} + response = self.app.post('/api/v1.0/auth/login',data=user) + self.assertEqual(response.status_code, 403) + + user = {'username':'tuser', 'password':'test'} + response = self.app.post('/api/v1.0/auth/login',data=user) + result = json.loads(response.data) + return result.get('token','') + + def setUp(self): + app.config.from_object('test_config') + self.app = app.test_client() + self.uid = 0 + self.token = '' + self.bucketlist_id = 0 + self.bucketlist_item_id = 0 + + + def remove_user(self): + """ Remove user record """ + + user = User.query.filter_by(id=self.uid).first() + if user: + delete(user) + + def remove_bucketlist(self): + """ Remove bucketlist record """ + + bucketlist = BucketListModel.query.filter_by(id=self.bucketlist_id).first() + if bucketlist: + delete(bucketlist) + + def remove_bucketlist_item(self): + """ Remove bucketlist item """ + + item = BucketListItemModel.query.filter_by(id=self.bucketlist_item_id).first() + if item: + delete(item) + + + def new_bucketlist(self): + """ Posting to bucketlists endpoint """ + + new_bucketlists = self.app.post('/api/v1.0/bucketlists', data={'name':'test item 1'}, + headers={'AccessToken':self.token}) + self.assertEqual(new_bucketlists.status_code, 201) + new_bucketlists_result = json.loads(new_bucketlists.data) + if new_bucketlists_result.get('data'): + self.bucketlist_id = new_bucketlists_result['data']['id'] + + + def new_bucketlist_item(self): + """ Posting to bucketlists endpoint """ + + new_bucketlist_item = self.app.post('/api/v1.0/bucketlists/0' + '/items', + data={'task':'buy a private jet'}, + headers={'AccessToken':self.token}) + self.assertEqual(new_bucketlist_item.status_code, 403) + + new_bucketlist_item = self.app.post('/api/v1.0/bucketlists/' + str(self.bucketlist_id) + '/items', + data={'task':'buy a private jet'}, + headers={'AccessToken':self.token}) + self.assertEqual(new_bucketlist_item.status_code, 201) + + new_bucketlist_item_result = json.loads(new_bucketlist_item.data) + self.assertNotEqual(new_bucketlist_item_result.get('data'), None) + if new_bucketlist_item_result: + self.bucketlist_item_id = new_bucketlist_item_result['data']['id'] + + def get_items(self): + """ Get bucketlist Items """ + get_items = self.app.get('/api/v1.0/bucketlists/' + str(self.bucketlist_id) + '/items', + headers={'AccessToken':self.token}) + self.assertEqual(get_items.status_code, 200) + get_result = json.loads(get_items.data) + self.assertNotEqual(get_result.get('data'), None) + + + def get_single_item(self): + """ Get bucketlist Item """ + + get_item = self.app.get('/api/v1.0/bucketlists/' + str(self.bucketlist_id) + '/items/' + str(self.bucketlist_item_id), + headers={'AccessToken':self.token}) + self.assertEqual(get_item.status_code, 200) + get_result = json.loads(get_item.data) + self.assertNotEqual(get_result.get('data'), None) + + def update_item(self): + """ Update bucketlist item """ + + put_item = self.app.put('/api/v1.0/bucketlists/0' + '/items/' + str(self.bucketlist_item_id), + data={'done':True}, headers={'AccessToken':self.token}) + self.assertEqual(put_item.status_code, 403) + + put_item = self.app.put('/api/v1.0/bucketlists/' + str(self.bucketlist_id) + '/items/' + str(self.bucketlist_item_id), + data={'done':True}, headers={'AccessToken':self.token}) + self.assertEqual(put_item.status_code, 201) + put_result = json.loads(put_item.data) + data = put_result.get('data') + self.assertEqual(data['done'], True) + + + def delete_bucketlist(self): + """ Delete bucketlists """ + + del_bucketlists = self.app.delete('/api/v1.0/bucketlists/'+ str(self.bucketlist_id), + headers={'AccessToken':self.token}) + self.assertEqual(del_bucketlists.status_code, 400) + + del_bucketlists = self.app.delete('/api/v1.0/bucketlists/0', + headers={'AccessToken':self.token}) + self.assertEqual(del_bucketlists.status_code, 403) + + def delete_item(self): + """ Delete bucketlist item """ + + del_item = self.app.delete('/api/v1.0/bucketlists/0'+ '/items/' + str(self.bucketlist_item_id), + headers={'AccessToken':self.token}) + self.assertEqual(del_item.status_code, 403) + + del_item = self.app.delete('/api/v1.0/bucketlists/'+ str(self.bucketlist_id) + '/items/' + str(self.bucketlist_item_id), + headers={'AccessToken':self.token}) + self.assertEqual(del_item.status_code, 204) + + def test_bucketlists_endpoint(self): + """ Test all endpoint of bucketlist items """ + + self.register() + self.new_bucketlist() + self.new_bucketlist_item() + self.get_items() + self.update_item() + self.get_single_item() + self.delete_bucketlist() + self.delete_item() + + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py new file mode 100644 index 0000000..026016e --- /dev/null +++ b/tests/test_edge_cases.py @@ -0,0 +1,56 @@ +import unittest +import json +from os import sys, path +sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) +from app import app, db +from app.models import * +from app.helper import * + + +class TestResources(unittest.TestCase): + """Test cases for Bucketlist Items""" + + def setUp(self): + app.config.from_object('test_config') + self.app = app.test_client() + self.uid = 0 + self.token = '' + self.bucketlist_id = 0 + self.bucketlist_item_id = 0 + + def test_helper_delete_exception(self): + bucketlist = BucketListModel('unknown', None) + self.assertEqual(delete(bucketlist), False) + + def test_helper_get_user_id_from_token_exception(self): + self.assertEqual(get_user_id_from_token('bucketlist'), 0) + + def test_helper_encrypt_exception(self): + self.assertEqual(encrypt(False), False) + + def test_helper_decrypt_exception(self): + self.assertEqual(decrypt(False), False) + + def test_model_repr(self): + user = User('guru','','') + bucketlist = BucketListModel('test', user) + bucketlist_item = BucketListItemModel('test', bucketlist) + print user, bucketlist, bucketlist_item + + def test_view_index(self): + response = self.app.get('/') + self.assertEqual(response.status_code, 200) + + def test_view_not_found(self): + response = self.app.get('/unknown') + self.assertEqual(response.status_code, 404) + + def test_resource_no_token(self): + response = self.app.get('/api/v1.0/bucketlists') + self.assertEqual(response.status_code, 403) + + + + +if __name__ == '__main__': + unittest.main() \ No newline at end of file