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