@ -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} |