From 32ac3d7a23adcae3c357c918518e59e1780987b6 Mon Sep 17 00:00:00 2001 From: Ryan Reed Date: Sun, 28 Apr 2019 17:31:13 -0400 Subject: [PATCH] Initial commit --- .flaskenv | 2 + .gitignore | 111 +++++++++++++++++ README.md | 112 +++++++++++++++++ app/__init__.py | 46 +++++++ app/api/__init__.py | 5 + app/api/auth.py | 29 +++++ app/api/errors.py | 13 ++ app/api/tokens.py | 18 +++ app/api/users.py | 56 +++++++++ app/auth/__init__.py | 5 + app/auth/forms.py | 10 ++ app/auth/routes.py | 33 +++++ app/auth/templates/login.html | 26 ++++ app/errors/__init__.py | 5 + app/errors/handlers.py | 25 ++++ app/errors/templates/404.html | 7 ++ app/errors/templates/500.html | 9 ++ app/main/__init__.py | 5 + app/main/routes.py | 12 ++ app/main/templates/base.html | 36 ++++++ app/main/templates/index.html | 11 ++ app/models.py | 117 ++++++++++++++++++ app/static/style.css | 1 + config.py | 18 +++ migrations/README | 1 + migrations/alembic.ini | 45 +++++++ migrations/env.py | 95 ++++++++++++++ migrations/script.py.mako | 24 ++++ ...adding_tokens_and_api_related_functions.py | 36 ++++++ .../e1b952dc4b28_initial_user_table.py | 46 +++++++ requirements.txt | 25 ++++ run.py | 22 ++++ 32 files changed, 1006 insertions(+) create mode 100644 .flaskenv create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app/__init__.py create mode 100644 app/api/__init__.py create mode 100644 app/api/auth.py create mode 100644 app/api/errors.py create mode 100644 app/api/tokens.py create mode 100644 app/api/users.py create mode 100644 app/auth/__init__.py create mode 100644 app/auth/forms.py create mode 100644 app/auth/routes.py create mode 100644 app/auth/templates/login.html create mode 100644 app/errors/__init__.py create mode 100644 app/errors/handlers.py create mode 100644 app/errors/templates/404.html create mode 100644 app/errors/templates/500.html create mode 100644 app/main/__init__.py create mode 100644 app/main/routes.py create mode 100644 app/main/templates/base.html create mode 100644 app/main/templates/index.html create mode 100644 app/models.py create mode 100644 app/static/style.css create mode 100644 config.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/7fb3d3939b8d_adding_tokens_and_api_related_functions.py create mode 100644 migrations/versions/e1b952dc4b28_initial_user_table.py create mode 100644 requirements.txt create mode 100755 run.py diff --git a/.flaskenv b/.flaskenv new file mode 100644 index 0000000..29da4b8 --- /dev/null +++ b/.flaskenv @@ -0,0 +1,2 @@ +FLASK_APP=run.py +FLASK_DEBUG=1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7410f73 --- /dev/null +++ b/.gitignore @@ -0,0 +1,111 @@ +# Swap files +*.sw[a-p] + +# Database file +*.db + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +venv-*/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d2a3228 --- /dev/null +++ b/README.md @@ -0,0 +1,112 @@ + +# Table of Contents + + +* [Setting up the environment](#setting-up-the-environment) + * [Install the requirements](#install-the-requirements) + * [Setup the environment variables](#setup-the-environment-variables) +* [The API](#the-api) +* [The Database](#the-database) + * [Workflow for updating database (new table/field/etc)](#workflow-for-updating-database-new-tablefieldetc) + * [Initial DB Setup for the app](#initial-db-setup-for-the-app) + * [Working with the database](#working-with-the-database) + * [Adding to the database](#adding-to-the-database) + * [Querying the database](#querying-the-database) + * [Clear out the tables](#clear-out-the-tables) + + + +# Setting up the environment + +## Install the requirements +``` +pip install -r requirements.txt +``` + +## Setup the environment variables +The following can be added to a `.env` file in the foot of this directory as well + +```bash +APP_ENVIRONMENT="PROD" +SECRET_KEY="myverysecretkey" +DATABASE_URL="sqlite:///app.db" +``` + +# The API + +The api allows for various functions. To work with it locally and easily, you can use `httpie` in Pypi or just use curl: +```bash +pip install httpie +``` + +The following includes various methods: +```bash +# Get token +http --auth : POST http://localhost:5000/api/tokens + +# Revoke token +http DELETE http://localhost:5000/api/tokens "Authorization:Bearer " + +# Create user +http POST http://localhost:5000/api/users "Authorization:Bearer " username=alice password=dog email=alice@example.com + +# Update current user +http PUT http://localhost:5000/api/users/1 "Authorization:Bearer " email=new_email@example.com + +# Various routes +http GET http://localhost:5000/api/users "Authorization:Bearer " +http GET http://localhost:5000/api/users/1 "Authorization:Bearer " +``` + + +# The Database + +## Workflow for updating database (new table/field/etc) +1. Export the flask app: `export FLASK_APP=stockpyle.py` +2. Create/modify class for table in `app/models.py` +3. Create migration script: `flask db migrate -m " table"` +4. Commit migration script to repo (for upgrading other environments) +5. Make the changes to the database: `flask db upgrade` + + +## Initial DB Setup for the app +```bash +flask db upgrade +``` + + +## Working with the database + +### Adding to the database +```python +u = User(username='john', email='john@example.com') +u.set_password('password') +db.session.add(u) + +u = User(username='susan', email='susan@example.com') +u.set_password('password') +db.session.add(u) + +db.session.commit() +``` + +### Querying the database +```python +# get user +u = User.query.get(1) +u +# + +# get all users in reverse alphabetical order +User.query.order_by(User.username.desc()).all() +#[, ] +``` + +### Clear out the tables +```python +users = User.query.all() +for u in users: + db.session.delete(u) + +db.session.commit() +``` diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ebfd455 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,46 @@ +from flask import Flask +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_sqlalchemy import SQLAlchemy + +from config import Config + + + +db = SQLAlchemy() +migrate = Migrate() +login = LoginManager() +login.login_view = "auth.login" +login.login_message = "Please log in to access this page." + + +def create_app(config_class=Config): + """ + The application instance to be used when initializing the app + + :param config_class: (Default value = Config) + :returns: The flask application + """ + app = Flask(__name__) + app.config.from_object(config_class) + + db.init_app(app) + migrate.init_app(app, db, render_as_batch=True) + login.init_app(app) + + from app.api import bp as bp_api + app.register_blueprint(bp_api, url_prefix='/api') + + from app.auth import bp as bp_auth + app.register_blueprint(bp_auth, url_prefix='/auth') + + from app.errors import bp as bp_errors + app.register_blueprint(bp_errors) + + from app.main import bp as bp_main + app.register_blueprint(bp_main) + + return app + + +from app import models diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..61b2e60 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('api', __name__) + +from app.api import users, errors, tokens diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..47c1902 --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,29 @@ +from app.api.errors import error_response +from app.models import User +from flask import g +from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth + +basic_auth = HTTPBasicAuth() +token_auth = HTTPTokenAuth() + +@basic_auth.verify_password +def verify_password(username, password): + user = User.query.filter_by(username=username).first() + if user is None: + return False + g.current_user = user + return user.check_password(password) + +@basic_auth.error_handler +def basic_auth_error(): + return error_response(401) + +@token_auth.verify_token +def verify_token(token): + g.current_user = User.check_token(token) if token else None + return g.current_user is not None + +@token_auth.error_handler +def token_auth_error(): + return error_response(401) + diff --git a/app/api/errors.py b/app/api/errors.py new file mode 100644 index 0000000..17b85cc --- /dev/null +++ b/app/api/errors.py @@ -0,0 +1,13 @@ +from flask import jsonify +from werkzeug.http import HTTP_STATUS_CODES + +def error_response(status_code, message=None): + payload = {'error': HTTP_STATUS_CODES.get(status_code, 'Unknown error')} + if message: + payload['message'] = message + response = jsonify(payload) + response.status_code = status_code + return response + +def bad_request(message): + return error_response(400, message) diff --git a/app/api/tokens.py b/app/api/tokens.py new file mode 100644 index 0000000..4fca001 --- /dev/null +++ b/app/api/tokens.py @@ -0,0 +1,18 @@ +from app import db +from app.api import bp +from app.api.auth import basic_auth, token_auth +from flask import jsonify, g + +@bp.route('/tokens', methods=['POST']) +@basic_auth.login_required +def get_token(): + token = g.current_user.get_token() + db.session.commit() + return jsonify({'token': token}) + +@bp.route('/tokens', methods=['DELETE']) +@token_auth.login_required +def revoke_token(): + g.current_user.revoke_token() + db.session.commit() + return '', 204 diff --git a/app/api/users.py b/app/api/users.py new file mode 100644 index 0000000..4e2efc7 --- /dev/null +++ b/app/api/users.py @@ -0,0 +1,56 @@ +from app import db +from app.api import bp +from app.api.auth import token_auth +from app.api.errors import bad_request +from app.models import User +from flask import abort, g, jsonify, request, url_for + +@bp.route('/users/', methods=['GET']) +@token_auth.login_required +def get_user(id): + return jsonify(User.query.get_or_404(id).to_dict()) + +@bp.route('/users', methods=['GET']) +@token_auth.login_required +def get_users(): + page = request.args.get('page', 1, type=int) + per_page = min(request.args.get('per_page', 10, type=int), 100) + data = User.to_collection_dict(User.query, page, per_page, 'api.get_users') + return jsonify(data) + +@bp.route('/users', methods=['POST']) +@token_auth.login_required +def create_user(): + data = request.get_json() or {} + if 'username' not in data or 'email' not in data or 'password' not in data: + return bad_request('must include username, email, and password fields') + if User.query.filter_by(username=data['username']).first(): + return bad_request('please use a different username') + if User.query.filter_by(email=data['email']).first(): + return bad_request('please use a different email') + user = User() + user.from_dict(data, new_user=True) + db.session.add(user) + db.session.commit() + response = jsonify(user.to_dict()) + response.status_code = 201 + response.headers['Location'] = url_for('api.get_user', id=user.id) + return response + +@bp.route('/users/', methods=['PUT']) +@token_auth.login_required +def update_user(id): + if g.current_user.id != id: + abort(403) + + user = User.query.get_or_404(id) + data = request.get_json() or {} + if 'username' in data and data['username'] != user.username and \ + User.query.filter_by(username=data['username']).first(): + return bad_request('please use a different username') + if 'email' in data and data['email'] != user.email and \ + User.query.filter_by(email=data['email']).first(): + return bad_request('please use a different email address') + user.from_dict(data, new_user=False) + db.session.commit() + return jsonify(user.to_dict()) diff --git a/app/auth/__init__.py b/app/auth/__init__.py new file mode 100644 index 0000000..3038f35 --- /dev/null +++ b/app/auth/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('auth', __name__, template_folder='templates') + +from app.auth import routes diff --git a/app/auth/forms.py b/app/auth/forms.py new file mode 100644 index 0000000..91b2464 --- /dev/null +++ b/app/auth/forms.py @@ -0,0 +1,10 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField, TextAreaField +from wtforms.validators import DataRequired, ValidationError, Email, EqualTo, Length +from app.models import User + +class LoginForm(FlaskForm): + username = StringField("Username", validators=[DataRequired()]) + password = PasswordField("Password", validators=[DataRequired()]) + remember_me = BooleanField("Remember Me") + submit = SubmitField("Sign In") diff --git a/app/auth/routes.py b/app/auth/routes.py new file mode 100644 index 0000000..7edc350 --- /dev/null +++ b/app/auth/routes.py @@ -0,0 +1,33 @@ +from app import db +from app.auth import bp +from app.auth.forms import LoginForm +from app.models import User +from flask import render_template, flash, redirect, request, url_for +from flask_login import current_user, login_user, logout_user +from werkzeug.urls import url_parse + +@bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('main.index')) + + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(username=form.username.data).first() + if user is None or not user.check_password(form.password.data): + flash('Invalid username or password') + return redirect(url_for('auth.login')) + + login_user(user, remember=form.remember_me.data) + next_page = request.args.get('next') + if not next_page or url_parse(next_page).netloc != '': + next_page = url_for('main.index') + + return redirect(next_page) + + return render_template('login.html', title='Sign In', form=form) + +@bp.route('/logout') +def logout(): + logout_user() + return redirect(url_for('main.index')) diff --git a/app/auth/templates/login.html b/app/auth/templates/login.html new file mode 100644 index 0000000..8e723f1 --- /dev/null +++ b/app/auth/templates/login.html @@ -0,0 +1,26 @@ +{% extends 'base.html' %} + +{% block app_content %} +

Sign In

+
+
+ {{ form.hidden_tag() }} +

+ {{ form.username.label }}
+ {{ form.username(size=32) }}
+ {% for error in form.username.errors %} + [{{ error }}] + {% endfor %} +

+

+ {{ form.password.label }}
+ {{ form.password(size=32) }}
+ {% for error in form.password.errors %} + [{{ error }}] + {% endfor %} +

+

{{ form.remember_me() }} {{ form.remember_me.label }}

+

{{ form.submit() }}

+
+
+{% endblock %} diff --git a/app/errors/__init__.py b/app/errors/__init__.py new file mode 100644 index 0000000..bd7391e --- /dev/null +++ b/app/errors/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('errors', __name__, template_folder='templates') + +from app.errors import handlers diff --git a/app/errors/handlers.py b/app/errors/handlers.py new file mode 100644 index 0000000..6f968c0 --- /dev/null +++ b/app/errors/handlers.py @@ -0,0 +1,25 @@ +from app import db +from app.errors import bp +from app.api.errors import error_response as api_error_response +from flask import render_template, request + +def wants_json_response(): + """ + if the client has JSON rated higher in their preferred response format + return true + """ + return request.accept_mimetypes['application/json'] >= \ + request.accept_mimetypes['text/html'] + +@bp.app_errorhandler(404) +def not_found_error(error): + if wants_json_response(): + return api_error_response(404) + return render_template('404.html'), 404 + +@bp.app_errorhandler(500) +def internal_error(error): + db.session.rollback() + if wants_json_response(): + return api_error_response(500) + return render_template('500.html'), 500 diff --git a/app/errors/templates/404.html b/app/errors/templates/404.html new file mode 100644 index 0000000..025325a --- /dev/null +++ b/app/errors/templates/404.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} + +{% block app_content %} +

File Not Found

+ +

Back

+{% endblock %} diff --git a/app/errors/templates/500.html b/app/errors/templates/500.html new file mode 100644 index 0000000..3639cf4 --- /dev/null +++ b/app/errors/templates/500.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} + +{% block app_content %} +

An unexpected error has occured

+ +

The administrator has been notified. Sorry for the inconvenience.

+ +

Back

+{% endblock %} diff --git a/app/main/__init__.py b/app/main/__init__.py new file mode 100644 index 0000000..6f81dd0 --- /dev/null +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__, template_folder='templates') + +from app.main import routes diff --git a/app/main/routes.py b/app/main/routes.py new file mode 100644 index 0000000..a503988 --- /dev/null +++ b/app/main/routes.py @@ -0,0 +1,12 @@ +from flask import current_app, render_template + +from app.main import bp + + +@bp.route("/", methods=["GET", "POST"]) +@bp.route("/index", methods=["GET", "POST"]) +def index(): + return render_template("index.html", + title="Home Page", + environment=current_app.config["ENVIRONMENT"] + ) diff --git a/app/main/templates/base.html b/app/main/templates/base.html new file mode 100644 index 0000000..b73d1d8 --- /dev/null +++ b/app/main/templates/base.html @@ -0,0 +1,36 @@ + + + + + [{{ environment }}] {% if title %}{{ title }}{% else %}Home Page{% endif %} + + {% block styles %} + + {% endblock %} + + {% block scripts %} + {% endblock %} + + +
+

[{{ environment }}] Home Page

+ + {% if current_user.is_anonymous %} +

Login

+ {% endif %} + {% if current_user.is_authenticated %} +

Hello {{ current_user.username }}

Logout + {% endif %} + + {% with messages = get_flashed_messages() %} + {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + + {% block app_content %}{% endblock %} +
+ + diff --git a/app/main/templates/index.html b/app/main/templates/index.html new file mode 100644 index 0000000..1119078 --- /dev/null +++ b/app/main/templates/index.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} + +{% block app_content %} +
+

This is the index. To hopefully be expanded upon

+
+{% endblock %} + +{% block styles %} + {{ super() }}{# Preserve existing styles #} +{% endblock %} diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..d55cfb4 --- /dev/null +++ b/app/models.py @@ -0,0 +1,117 @@ +from app import db, login +from datetime import datetime, timedelta +from flask import current_app, url_for +from flask_login import UserMixin +from time import time +from werkzeug.security import generate_password_hash, check_password_hash +import base64 +import json +import jwt +import os + + +class PaginatedAPIMixin(object): + @staticmethod + def to_collection_dict(query, page, per_page, endpoint, **kwargs): + resources = query.paginate(page, per_page, False) + data = { + 'items': [item.to_dict() for item in resources.items], + '_meta': { + 'page': page, + 'per_page': per_page, + 'total_pages': resources.pages, + 'total_items': resources.total + }, + '_links': { + 'self': url_for(endpoint, page=page, per_page=per_page, + **kwargs), + 'next': url_for(endpoint, page=page, per_page=per_page, + **kwargs) if resources.has_next else None, + 'prev': url_for(endpoint, page=page, per_page=per_page, + **kwargs) if resources.has_prev else None, + } + } + return data + +class User(PaginatedAPIMixin, UserMixin, db.Model): + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(64), index=True, unique=True) + email = db.Column(db.String(120), index=True, unique=True) + password_hash = db.Column(db.String(128)) + last_seen = db.Column(db.DateTime, default=datetime.utcnow) + role = db.Column(db.String(32), index=True) + token = db.Column(db.String(32), index=True, unique=True) + token_expiration = db.Column(db.DateTime) + + def __repr__(self): + return f"" + + def set_password(self, password): + """ + Set the password_hash field + + :param password: + """ + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + """ + Check the password against the hash in the database + + :param password: + returns: True/False + """ + return check_password_hash(self.password_hash, password) + + def from_dict(self, data, new_user=False): + for field in ['username', 'email']: + if field in data: + setattr(self, field, data[field]) + if new_user and 'password' in data: + self.set_password(data['password']) + + def to_dict(self, include_email=False): + data = { + 'id': self.id, + 'username': self.username, + 'last_seen': self.last_seen.isoformat() + 'Z', + '_links': { + 'self': url_for('api.get_user', id=self.id), + } + } + if include_email: + data['email'] = self.email + return data + + def get_token(self, expires_in=3600): + now = datetime.utcnow() + if self.token and self.token_expiration > now + timedelta(seconds=60): + return self.token + self.token = base64.b64encode(os.urandom(24)).decode('utf-8') + self.token_expiration = now + timedelta(seconds=expires_in) + db.session.add(self) + return self.token + + def revoke_token(self): + self.token_expiration = datetime.utcnow() - timedelta(seconds=1) + + @staticmethod + def check_token(token): + user = User.query.filter_by(token=token).first() + if user is None or user.token_expiration < datetime.utcnow(): + return None + return user + + @staticmethod + def verify_reset_password_token(token): + try: + id = jwt.decode(token, current_app.config['SECRET_KEY'], + algorithms=['HS256'])['reset_password'] + except: + return + return User.query.get(id) + +@login.user_loader +def load_user(id): + # Necessary for flask-login to work + return User.query.get(int(id)) diff --git a/app/static/style.css b/app/static/style.css new file mode 100644 index 0000000..b8a5474 --- /dev/null +++ b/app/static/style.css @@ -0,0 +1 @@ +.alert-error, .alert-info { color: red; } diff --git a/config.py b/config.py new file mode 100644 index 0000000..b9765cd --- /dev/null +++ b/config.py @@ -0,0 +1,18 @@ +import os + + +class Config(): + """ + The configuration class. Retrieves needed environment information. + + Accessed via app.config or current_app.config + """ + BASEDIR = os.path.abspath(os.path.dirname(__file__)) + + ENVIRONMENT = os.environ.get("APP_ENVIRONMENT") or "DEV" + + SECRET_KEY = os.environ.get("SECRET_KEY") or "you-will-never-guess" + SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL") or "sqlite:///{}".format( + os.path.join(BASEDIR, "app.db") + ) + SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/migrations/README b/migrations/README new file mode 100644 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 100644 index 0000000..169d487 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,95 @@ +from __future__ import with_statement + +import logging +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context + +# 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, target_metadata=target_metadata, literal_binds=True + ) + + 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.zzzcomputing.com/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.') + + connectable = engine_from_config( + config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +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 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/7fb3d3939b8d_adding_tokens_and_api_related_functions.py b/migrations/versions/7fb3d3939b8d_adding_tokens_and_api_related_functions.py new file mode 100644 index 0000000..c25dcce --- /dev/null +++ b/migrations/versions/7fb3d3939b8d_adding_tokens_and_api_related_functions.py @@ -0,0 +1,36 @@ +"""Adding tokens and api related functions + +Revision ID: 7fb3d3939b8d +Revises: e1b952dc4b28 +Create Date: 2019-04-28 16:56:03.996324 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '7fb3d3939b8d' +down_revision = 'e1b952dc4b28' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.add_column(sa.Column('token', sa.String(length=32), nullable=True)) + batch_op.add_column(sa.Column('token_expiration', sa.DateTime(), nullable=True)) + batch_op.create_index(batch_op.f('ix_user_token'), ['token'], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_token')) + batch_op.drop_column('token_expiration') + batch_op.drop_column('token') + + # ### end Alembic commands ### diff --git a/migrations/versions/e1b952dc4b28_initial_user_table.py b/migrations/versions/e1b952dc4b28_initial_user_table.py new file mode 100644 index 0000000..8c85546 --- /dev/null +++ b/migrations/versions/e1b952dc4b28_initial_user_table.py @@ -0,0 +1,46 @@ +"""Initial User table + +Revision ID: e1b952dc4b28 +Revises: +Create Date: 2019-04-28 14:56:04.793653 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'e1b952dc4b28' +down_revision = None +branch_labels = None +depends_on = None + + +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=64), nullable=True), + sa.Column('email', sa.String(length=120), nullable=True), + sa.Column('password_hash', sa.String(length=128), nullable=True), + sa.Column('last_seen', sa.DateTime(), nullable=True), + sa.Column('role', sa.String(length=32), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_user_email'), ['email'], unique=True) + batch_op.create_index(batch_op.f('ix_user_role'), ['role'], unique=False) + batch_op.create_index(batch_op.f('ix_user_username'), ['username'], unique=True) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_user_username')) + batch_op.drop_index(batch_op.f('ix_user_role')) + batch_op.drop_index(batch_op.f('ix_user_email')) + + op.drop_table('user') + # ### end Alembic commands ### diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1021586 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +alembic==1.0.9 +asn1crypto==0.24.0 +cffi==1.12.3 +Click==7.0 +cryptography==2.6.1 +Flask==1.0.2 +Flask-HTTPAuth==3.2.4 +Flask-Login==0.4.1 +Flask-Migrate==2.4.0 +Flask-SQLAlchemy==2.4.0 +Flask-WTF==0.14.2 +itsdangerous==1.1.0 +Jinja2==2.10.1 +jwt==0.6.1 +Mako==1.0.9 +MarkupSafe==1.1.1 +pkg-resources==0.0.0 +pycparser==2.19 +python-dateutil==2.8.0 +python-dotenv==0.10.1 +python-editor==1.0.4 +six==1.12.0 +SQLAlchemy==1.3.3 +Werkzeug==0.15.2 +WTForms==2.2.1 diff --git a/run.py b/run.py new file mode 100755 index 0000000..3c89d25 --- /dev/null +++ b/run.py @@ -0,0 +1,22 @@ +from app import create_app, db +from app.models import User + +app = create_app() + + +@app.shell_context_processor +def make_shell_context(): + """ + Make the following easily accessible from 'flask shell' + + For instance: + |--$ flask shell + Python 3.6.7 (default, Oct 22 2018, 11:32:17) + [GCC 8.2.0] on linux + App: spark [production] + Instance: /home/user/app/instance + >>> user = User.query.first() + >>> print(user) + + """ + return {"db": db, "User": User}