@ -0,0 +1,2 @@ | |||
FLASK_APP=run.py | |||
FLASK_DEBUG=1 |
@ -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/ |
@ -0,0 +1,112 @@ | |||
# Table of Contents | |||
<!-- vim-markdown-toc GFM --> | |||
* [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) | |||
<!-- vim-markdown-toc --> | |||
# 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 <username>:<password> POST http://localhost:5000/api/tokens | |||
# Revoke token | |||
http DELETE http://localhost:5000/api/tokens "Authorization:Bearer <token>" | |||
# Create user | |||
http POST http://localhost:5000/api/users "Authorization:Bearer <token>" username=alice password=dog email=alice@example.com | |||
# Update current user | |||
http PUT http://localhost:5000/api/users/1 "Authorization:Bearer <token>" email=new_email@example.com | |||
# Various routes | |||
http GET http://localhost:5000/api/users "Authorization:Bearer <token>" | |||
http GET http://localhost:5000/api/users/1 "Authorization:Bearer <token>" | |||
``` | |||
# 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 "<class/table> 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 | |||
#<User john> | |||
# get all users in reverse alphabetical order | |||
User.query.order_by(User.username.desc()).all() | |||
#[<User susan>, <User john>] | |||
``` | |||
### Clear out the tables | |||
```python | |||
users = User.query.all() | |||
for u in users: | |||
db.session.delete(u) | |||
db.session.commit() | |||
``` |
@ -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 |
@ -0,0 +1,5 @@ | |||
from flask import Blueprint | |||
bp = Blueprint('api', __name__) | |||
from app.api import users, errors, tokens |
@ -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) | |||
@ -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) |
@ -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 |
@ -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/<int:id>', 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/<int:id>', 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()) |
@ -0,0 +1,5 @@ | |||
from flask import Blueprint | |||
bp = Blueprint('auth', __name__, template_folder='templates') | |||
from app.auth import routes |
@ -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") |
@ -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')) |
@ -0,0 +1,26 @@ | |||
{% extends 'base.html' %} | |||
{% block app_content %} | |||
<h1>Sign In</h1> | |||
<div class="row"> | |||
<form action="" method="post" novalidate> | |||
{{ form.hidden_tag() }} | |||
<p> | |||
{{ form.username.label }}<br> | |||
{{ form.username(size=32) }}<br> | |||
{% for error in form.username.errors %} | |||
<span class="alert-error">[{{ error }}]</span> | |||
{% endfor %} | |||
</p> | |||
<p> | |||
{{ form.password.label }}<br> | |||
{{ form.password(size=32) }}<br> | |||
{% for error in form.password.errors %} | |||
<span class="alert-error">[{{ error }}]</span> | |||
{% endfor %} | |||
</p> | |||
<p>{{ form.remember_me() }} {{ form.remember_me.label }}</p> | |||
<p>{{ form.submit() }}</p> | |||
</form> | |||
</div> | |||
{% endblock %} |
@ -0,0 +1,5 @@ | |||
from flask import Blueprint | |||
bp = Blueprint('errors', __name__, template_folder='templates') | |||
from app.errors import handlers |
@ -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 |
@ -0,0 +1,7 @@ | |||
{% extends 'base.html' %} | |||
{% block app_content %} | |||
<h1>File Not Found</h1> | |||
<p><a href="{{ url_for('main.index') }}">Back</a></p> | |||
{% endblock %} |
@ -0,0 +1,9 @@ | |||
{% extends 'base.html' %} | |||
{% block app_content %} | |||
<h1>An unexpected error has occured</h1> | |||
<p>The administrator has been notified. Sorry for the inconvenience.</p> | |||
<p><a href="{{ url_for('main.index') }}">Back</a></p> | |||
{% endblock %} |
@ -0,0 +1,5 @@ | |||
from flask import Blueprint | |||
bp = Blueprint('main', __name__, template_folder='templates') | |||
from app.main import routes |
@ -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"] | |||
) |
@ -0,0 +1,36 @@ | |||
<!DOCTYPE html> | |||
<html lang="en"> | |||
<head> | |||
<meta charset=”utf-8”> | |||
<title>[{{ environment }}] {% if title %}{{ title }}{% else %}Home Page{% endif %}</title> | |||
{% block styles %} | |||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}" /> | |||
{% endblock %} | |||
{% block scripts %} | |||
{% endblock %} | |||
</head> | |||
<body> | |||
<div class="container"> | |||
<h1>[{{ environment }}] Home Page</h1> | |||
{% if current_user.is_anonymous %} | |||
<p><a href="{{ url_for('auth.login') }}">Login</a></p> | |||
{% endif %} | |||
{% if current_user.is_authenticated %} | |||
<p>Hello {{ current_user.username }}</p> <a href="{{ url_for('auth.logout') }}">Logout</a></li> | |||
{% endif %} | |||
{% with messages = get_flashed_messages() %} | |||
{% if messages %} | |||
{% for message in messages %} | |||
<div class="alert alert-info" role="alert">{{ message }}</div> | |||
{% endfor %} | |||
{% endif %} | |||
{% endwith %} | |||
{% block app_content %}{% endblock %} | |||
</div> | |||
</body> | |||
</html> |
@ -0,0 +1,11 @@ | |||
{% extends 'base.html' %} | |||
{% block app_content %} | |||
<div> | |||
<p>This is the index. To hopefully be expanded upon</p> | |||
</div> | |||
{% endblock %} | |||
{% block styles %} | |||
{{ super() }}{# Preserve existing styles #} | |||
{% endblock %} |
@ -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"<User {self.username}>" | |||
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)) |
@ -0,0 +1 @@ | |||
.alert-error, .alert-info { color: red; } |
@ -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 |
@ -0,0 +1 @@ | |||
Generic single-database configuration. |
@ -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 |
@ -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() |
@ -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"} |
@ -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 ### |
@ -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 ### |
@ -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 |
@ -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) | |||
<User johnwhite> | |||
""" | |||
return {"db": db, "User": User} |