diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcb9495 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Python cache +__pycache__/ +*.py[cod] + +# VirtualEnv +.Python +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +.venv +*.egg +pip-selfcheck.json + +# sqLite +*.db +*.sqlite* + +# Pycharm +.idea + +# Tox +.tox + +# Coverage +htmlcov/ +.coverage +coverage.xml diff --git a/backend/app/api/exampleapi/controller.py b/backend/app/api/exampleapi/controller.py new file mode 100644 index 0000000..f1d625d --- /dev/null +++ b/backend/app/api/exampleapi/controller.py @@ -0,0 +1,18 @@ +from flask_restful import Resource + + +class SomeApi(Resource): + """ + Some Api Resource + """ + def post(self): + return {'somepost': 'somepostdata'}, 201 + + def get(self, id=None): + return {'someget': 'somegetdata'}, 200 + + def delete(self, id=None): + return {'somedelete': 'somedeletedata'}, 204 + + def put(self, id=None): + return {'someput': 'someputdata'}, 204 diff --git a/backend/app/api/exampleapi/model.py b/backend/app/api/exampleapi/model.py new file mode 100644 index 0000000..5cdc0bb --- /dev/null +++ b/backend/app/api/exampleapi/model.py @@ -0,0 +1,33 @@ +from passlib.apps import custom_app_context as pwd_context + +from app.core import db + +users_roles = db.Table('users_roles', + db.Column('user_id', + db.Integer, + db.ForeignKey('user.id') + ), + db.Column('role_id', + db.Integer, + db.ForeignKey('role.id') + ) + ) + + +class Role(db.Model): + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(80), unique=True, nullable=False) + description = db.Column(db.String(255), nullable=False) + + +class User(db.Model): + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(255), index=True, unique=True, nullable=False) + password_hash = db.Column(db.String(64), nullable=False) + roles = db.relationship('Role', secondary=users_roles) + + def hash_password(self, password): + self.password_hash = pwd_context.encrypt(password) + + def verify_password(self, password): + return pwd_context.verify(password, self.password_hash) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..538c270 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,31 @@ +import os + + +class Config: + DEBUG = False + TESTING = False + BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + SQLALCHEMY_DATABASE_URI = \ + 'sqlite:///' + os.path.join(BASE_DIR, '../app.db') + SQLALCHEMY_TRACK_MODIFICATIONS = False + THREADS_PER_PAGE = 2 + SECRET_KEY = "secret" + BUNDLE_ERRORS = True + SESSION_COOKIE_SECURE = True + SESSION_VALIDITY_DURATION_WITHOUT_ACTIVITY_MIN = 20 + + +class Prod(Config): + SQLALCHEMY_DATABASE_URI = 'mysql://user@localhost/foo' + + +class Debug(Config): + DEBUG = True + SESSION_COOKIE_SECURE = False + + +class Test(Config): + TESTING = True + BASE_DIR = os.path.abspath(os.path.dirname(__file__)) + SQLALCHEMY_DATABASE_URI = \ + 'sqlite:///' + os.path.join(BASE_DIR, '../test.db') diff --git a/backend/app/core.py b/backend/app/core.py new file mode 100644 index 0000000..4d093bb --- /dev/null +++ b/backend/app/core.py @@ -0,0 +1,40 @@ +from datetime import timedelta + +from flask import Flask, session +from flask_restful import Api +from flask_sqlalchemy import SQLAlchemy + +import importlib + + +def configure_app(config="prod"): + if config.lower() == "debug": + app.config.from_object('app.config.Debug') + elif config.lower() == "test": + app.config.from_object('app.config.Test') + else: + app.config.from_object('app.config.Prod') + + app.permanent_session_lifetime = \ + timedelta( + minutes=app.config + ['SESSION_VALIDITY_DURATION_WITHOUT_ACTIVITY_MIN'] + ) + + @app.before_request + def before_request(): + session.modified = True + + +# initialization Flask +app = Flask(__name__) +configure_app() + +# SQLAlchemy +db = SQLAlchemy(app) + +# RestFul Flask +api = Api(app) + +# import api resources +importlib.import_module("app.urls") diff --git a/backend/app/urls.py b/backend/app/urls.py new file mode 100644 index 0000000..70dc355 --- /dev/null +++ b/backend/app/urls.py @@ -0,0 +1,4 @@ +from app.core import api + +# Some Api resource +api.add_resource(api, '/api/someapi', '/api/someapi/') diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..f0008c9 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,74 @@ +import argparse +import os + +import unittest +import warnings + +from flask_migrate import Migrate, MigrateCommand +from flask_script import Manager, Command +from flask_script import prompt_bool + +from backend.app.core import app, db, configure_app + + +warnings.simplefilter('ignore') + +parser = argparse.ArgumentParser() +group = parser.add_mutually_exclusive_group() +group.add_argument("-d", "--debug", action="store_true") +group.add_argument("-t", "--test", action="store_true") +args, _ = parser.parse_known_args() + +if args.debug: + configure_app(config="debug") +if args.test: + configure_app(config="test") + +migrate = Migrate(app, db) +manager = Manager(app) +manager.add_option("-d", "--debug", + action="store_true", dest="debug", required=False) +manager.add_option("-t", "--test", + action="store_true", dest="test", required=False) + +# migrations : python manage.py db to show usage +manager.add_command('db', MigrateCommand) + + +class SeedDB(Command): + """Seed the db """ + def run(self): + if args.test: + raise Exception("Test Database is seed in test case tear up !") + #some seed method + + +manager.add_command('seeddb', SeedDB()) + + +class DropDB(Command): + """drop db """ + def run(self): + if prompt_bool("Are you sure you want to lose all your data"): + os.system("python manage.py -t db downgrade base") + + +manager.add_command('dropdb', DropDB()) + + +class RunTests(Command): + """Seed the db """ + def run(self): + configure_app(config="test") + os.system("python manage.py -t db downgrade base") + os.system("python manage.py -t db upgrade") + test_loader = unittest.defaultTestLoader + test_runner = unittest.TextTestRunner() + test_suite = test_loader.discover('tests') + test_runner.run(test_suite) + + +manager.add_command('runtests', RunTests()) + +if __name__ == '__main__': + manager.run() diff --git a/backend/requirements/common.txt b/backend/requirements/common.txt new file mode 100644 index 0000000..22226af --- /dev/null +++ b/backend/requirements/common.txt @@ -0,0 +1,7 @@ +flask < 0.13 +flask-script < 2.1 +flask-sqlalchemy < 2.2 +flask-migrate < 2.1 +flask-bootstrap < 3.4 +flask-restful < 0.4 +passlib < 1.8 \ No newline at end of file diff --git a/backend/requirements/test.txt b/backend/requirements/test.txt new file mode 100644 index 0000000..ae266f8 --- /dev/null +++ b/backend/requirements/test.txt @@ -0,0 +1,4 @@ +-r common.txt +tox +discover +coverage diff --git a/backend/tests/api/example_test_someapi.py b/backend/tests/api/example_test_someapi.py new file mode 100644 index 0000000..12c3594 --- /dev/null +++ b/backend/tests/api/example_test_someapi.py @@ -0,0 +1,170 @@ +from flask import json + +from backend.app.core import app +import unittest + + +class AuthTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + pass + + @classmethod + def tearDownClass(cls): + pass + + def setUp(self): + self.app = app.test_client() + + def tearDown(self): + pass + + def login(self, email, password): + return self.app.post('/api/auth', + data=json.dumps( + dict( + email=email, + password=password + ) + ), content_type='application/json') + + def logout(self): + return self.app.get('/api/auth') + + def create_user(self, email, password, confirm): + return self.app.post('/api/user', + data=json.dumps( + dict( + email=email, + password=password, + confirm=confirm + ) + ), content_type='application/json') + + def get_user(self, user_id=None): + if user_id: + return self.app.get('api/user/{}'.format(user_id)) + return self.app.get('api/user') + + def delete_user(self, user_id): + return self.app.delete('api/user/{}'.format(user_id)) + + def get_status(self): + return self.app.get('api/auth/status') + + def test_login_logout(self): + rv = self.login('admin@admin.com', 'admin') + self.assertEqual(rv.status_code, 204, 'Login as admin Failed') + + rv = self.get_status() + self.assertEqual(rv.status_code, 200, + 'Status problem : should be auth') + + rv = self.logout() + self.assertEqual(rv.status_code, 204, 'Logout Failed') + + rv = self.login('adminx', 'admin') + self.assertEqual(rv.status_code, 401) + self.assertIn('Invalid email format', json.loads(rv.data)['message']['email'], + 'Should return : invalid format email') + + rv = self.login('admin@admin.com', 'default') + self.assertEqual(rv.status_code, 401) + self.assertIn('invalid email/password', json.loads(rv.data)['message'], + 'Login invalid password unexpected return') + + rv = self.login('admin@admin.comx', 'admin') + self.assertEqual(rv.status_code, 401) + self.assertIn('invalid email/password', json.loads(rv.data)['message'], + 'Login with invalid mail unexpected retutn') + + def test_add_user(self): + rv = self.create_user('paul@paul.fr', 'superpassword', 'superpassword') + self.assertEqual(rv.status_code, 401, + 'Not connected user shouldn\'t be allow to add user') + + rv = self.login('admin@admin.com', 'admin') + self.assertEqual(rv.status_code, 204, 'Login as admin Failed') + + rv = self.create_user('paulatpaul.fr', 'superpassword', 'superpassword') + self.assertEqual(rv.status_code, 401) + self.assertIn('Invalid email format', json.loads(rv.data)['message']['email'], + 'Should return : invalid format email') + + rv = self.create_user('paul@paul.fr', 'super', 'super') + self.assertEqual(rv.status_code, 401) + self.assertIn('Password minimum length 6', json.loads(rv.data)['message']['password'], + 'Should return : Password minimum length 6') + + rv = self.create_user('paul@paul.fr', 'superpassword', 'superpass') + self.assertEqual(rv.status_code, 401) + self.assertIn('Password and confirmation are not the same', json.loads(rv.data)['message']['password'], + 'Should return : Password and confirmation are not the same') + + rv = self.create_user('paul@paul.fr', 'superpassword', 'superpassword') + self.assertEqual(rv.status_code, 201, + 'Add user failed') + + rv = self.create_user('paul@paul.fr', 'superpassword', 'superpassword') + self.assertEqual(rv.status_code, 401, + 'Should not can add a user with a email already in user') + self.assertIn('email already in use', json.loads(rv.data)['message'], + 'Bad error message') + + rv = self.login('paul@paul.fr', 'superpassword') + self.assertEqual(rv.status_code, 204, + 'Can\' login with new user !') + + rv = self.get_status() + self.assertEqual(rv.status_code, 200, + 'Status problem : should be auth') + new_user_id = json.loads(rv.data)['id'] + + rv = self.get_user(new_user_id) + self.assertEqual(rv.status_code, 200, + 'Can\'t get the new user') + self.assertEqual('paul@paul.fr', json.loads(rv.data)['email'], + 'The new user email is invalid') + + def test_get_user(self): + rv = self.get_user(user_id=1) + self.assertEqual(rv.status_code, 401, + 'Not connected user shouldn\'t be allow to get user') + + rv = self.login('admin@admin.com', 'admin') + self.assertEqual(rv.status_code, 204, 'Login as admin Failed') + + rv = self.get_user() + self.assertEqual(rv.status_code, 200) + users = json.loads(rv.data)['users'] + results = User.query.all() + self.assertEqual(len(users), len(results)) + + for user, result in zip(users, results): + self.assertEqual(user['email'], result.email) + + rv = self.get_user(user_id=1111111111) + self.assertEqual(rv.status_code, 404) + + rv = self.get_user(user_id=1) + self.assertEqual(rv.status_code, 200) + self.assertEqual(User.query.get(1).email, json.loads(rv.data)['email']) + + def test_delete_user(self): + rv = self.delete_user(user_id=2) + self.assertEqual(rv.status_code, 401, + 'Not connected user shouldn\'t be allow to delte user') + + rv = self.login('admin@admin.com', 'admin') + self.assertEqual(rv.status_code, 204, 'Login as admin Failed') + + rv = self.delete_user(user_id=1111111111) + self.assertEqual(rv.status_code, 404) + + rv = self.delete_user(user_id=2) + self.assertEqual(rv.status_code, 204) + self.assertIsNone(User.query.get(2)) + + +if __name__ == '__main__': + unittest.main() diff --git a/backend/tox.ini b/backend/tox.ini new file mode 100644 index 0000000..5a98442 --- /dev/null +++ b/backend/tox.ini @@ -0,0 +1,14 @@ +[flake8] +exclude = .git, app/static, app/templates, tox.ini + +[tox] +#envlist = py25,py26,py27 +skipsdist = True + +[testenv] +commands= + coverage run --source app/api --omit app/api/*/model.py manage.py runtests + coverage report -m + coverage xml + coverage html +deps=-r{toxinidir}/requirements/test.txt