├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── app ├── __init__.py ├── assets.py ├── auth │ ├── __init__.py │ ├── forms.py │ ├── templates │ │ ├── login.html │ │ ├── mail │ │ │ └── registration.mail │ │ └── register.html │ └── views.py ├── commands.py ├── config.py ├── database.py ├── extensions.py ├── jobs.py ├── static │ ├── css │ │ └── style.css │ └── js │ │ └── application.js ├── templates │ ├── components │ │ ├── field.html │ │ ├── flash.html │ │ ├── menu.html │ │ ├── pagination.html │ │ ├── search.html │ │ └── table.html │ ├── errors │ │ ├── 401.html │ │ ├── 404.html │ │ └── 500.html │ ├── index.html │ └── layout.html ├── user │ ├── __init__.py │ ├── forms.py │ ├── models.py │ ├── templates │ │ ├── edit.html │ │ ├── list.html │ │ └── users.html │ └── views.py └── utils.py ├── babel.cfg ├── docker-compose.yml ├── i18n └── messages.pot ├── image.jpg ├── migrations ├── README ├── alembic.ini ├── env.py ├── script.py.mako └── versions │ └── 1fb7c6da302_.py ├── package.json ├── pytest.ini ├── requirements.txt ├── serve.py ├── tests.py └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .DS_Store 3 | .coverage 4 | .env 5 | gen/ 6 | .webassets-cache 7 | bower_components/ 8 | node_modules/ 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | services: 3 | - docker 4 | addons: 5 | apt: 6 | sources: 7 | - sourceline: 'deb https://dl.yarnpkg.com/debian/ stable main' 8 | key_url: 'https://dl.yarnpkg.com/debian/pubkey.gpg' 9 | packages: 10 | - yarn 11 | before_install: 12 | - docker-compose up -d 13 | - sleep 5 14 | - docker-compose run --rm app flask create-db 15 | - docker-compose run --rm app flask populate-db --num_users 5 16 | install: 17 | - make assets 18 | script: 19 | - docker-compose run --rm app pytest 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6.3 2 | 3 | MAINTAINER Corey Burmeister "burmeister.corey@gmail.com" 4 | 5 | RUN mkdir -p /var/www/flask-bones 6 | WORKDIR /var/www/flask-bones 7 | 8 | ADD requirements.txt /var/www/flask-bones/ 9 | RUN pip install -r requirements.txt 10 | 11 | ADD . /var/www/flask-bones 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (C) 2017 by Corey Burmeister 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: init clean assets db 2 | 3 | init: 4 | pip install -r requirements.txt 5 | 6 | clean: 7 | find . -name '*.pyc' -delete 8 | 9 | assets: 10 | rm -rf ./app/static/node_modules 11 | yarn install --modules-folder ./app/static/node_modules 12 | 13 | db: 14 | flask recreate_db 15 | flask populate_db --num_users 5 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![flasks](https://raw.githubusercontent.com/cburmeister/flask-bones/master/image.jpg) 2 | 3 | flask-bones 4 | =========== 5 | 6 | An example of a large scale Flask application using blueprints and extensions. 7 | 8 | [![Build Status](https://travis-ci.org/cburmeister/flask-bones.svg?branch=master)](https://travis-ci.org/cburmeister/flask-bones) 9 | 10 | ## Setup 11 | 12 | Quickly run the project using [docker](https://www.docker.com/) and 13 | [docker-compose](https://docs.docker.com/compose/): 14 | ```bash 15 | docker-compose up -d 16 | ``` 17 | 18 | Create the database and seed it with some data: 19 | ```bash 20 | docker-compose run --rm app flask create-db 21 | docker-compose run --rm app flask populate-db --num_users 5 22 | ``` 23 | 24 | Download front-end dependencies with [yarn](https://yarnpkg.com/en/): 25 | ```bash 26 | yarn install --modules-folder ./app/static/node_modules 27 | ``` 28 | 29 | ## Configuration 30 | 31 | The following environment variables are *optional*: 32 | 33 | | Name | Purpose | 34 | |------------------|--------------------------------------------------| 35 | | `APP_NAME` | The name of the application. i.e Flask Bones | 36 | | `MAIL_PORT` | The port number of an SMTP server. | 37 | | `MAIL_SERVER` | The hostname of an SMTP server. | 38 | | `MEMCACHED_HOST` | The hostname of a memcached server. | 39 | | `MEMCACHED_PORT` | The port number of a memcached server. | 40 | | `POSTGRES_HOST` | The hostname of a postgres database server. | 41 | | `POSTGRES_PASS` | The password of a postgres database user. | 42 | | `POSTGRES_PORT` | The port number of a postgres database server. | 43 | | `POSTGRES_USER` | The name of a postgres database user. | 44 | | `REDIS_HOST` | The hostname of a redis database server. | 45 | | `REDIS_PORT` | The port number of a redis database server. | 46 | | `SECRET_KEY` | A secret key required to provide authentication. | 47 | | `SERVER_NAME` | The hostname and port number of the server. | 48 | 49 | ## Features 50 | 51 | ### Caching with Memcached 52 | 53 | ```python 54 | from app.extensions import cache 55 | 56 | # Cache something 57 | cache.set('some_key', 'some_value') 58 | 59 | # Fetch it later 60 | cache.get('some_key') 61 | ``` 62 | 63 | ### Email delivery 64 | 65 | ```python 66 | from app.extensions import mail 67 | from flask_mail import Message 68 | 69 | # Build an email 70 | msg = Message('User Registration', sender='admin@flask-bones.com', recipients=[user.email]) 71 | msg.body = render_template('mail/registration.mail', user=user, token=token) 72 | 73 | # Send 74 | mail.send(msg) 75 | ``` 76 | 77 | ### Asynchronous job scheduling with RQ 78 | 79 | `RQ` is a [simple job queue](http://python-rq.org/) for python backed by 80 | [redis](https://redis.io/). 81 | 82 | Define a job: 83 | ```python 84 | @rq.job 85 | def send_email(msg): 86 | mail.send(msg) 87 | ``` 88 | 89 | Start a worker: 90 | ```bash 91 | flask rq worker 92 | ``` 93 | 94 | Queue the job for processing: 95 | ```python 96 | send_email.queue(msg) 97 | ``` 98 | 99 | Monitor the status of the queue: 100 | ```bash 101 | flask rq info --interval 3 102 | ``` 103 | 104 | For help on all available commands: 105 | ```bash 106 | flask rq --help 107 | ``` 108 | 109 | ### Stupid simple user management 110 | 111 | ```python 112 | from app.extensions import login_user, logout_user, login_required 113 | 114 | # Login user 115 | login_user(user) 116 | 117 | # You now have a global proxy for the user 118 | current_user.is_authenticated 119 | 120 | # Secure endpoints with a decorator 121 | @login_required 122 | 123 | # Log out user 124 | logout_user() 125 | ``` 126 | 127 | ### Password security that can keep up with Moores Law 128 | 129 | ```python 130 | from app.extensions import bcrypt 131 | 132 | # Hash password 133 | pw_hash = bcrypt.generate_password_hash('password') 134 | 135 | # Validate password 136 | bcrypt.check_password_hash(pw_hash, 'password') 137 | ``` 138 | 139 | ### Easily swap between multiple application configurations 140 | 141 | ```python 142 | from app.config import dev_config, test_config 143 | 144 | app = Flask(__name__) 145 | 146 | class dev_config(): 147 | DEBUG = True 148 | 149 | class test_config(): 150 | TESTING = True 151 | 152 | # Configure for testing 153 | app.config.from_object(test_config) 154 | 155 | # Configure for development 156 | app.config.from_object(dev_config) 157 | ``` 158 | 159 | ### Form validation & CSRF protection with WTForms 160 | 161 | Place a csrf token on a form: 162 | ```html 163 | {{ form.csrf_token }} 164 | ``` 165 | 166 | Validate it: 167 | ```python 168 | form.validate_on_submit() 169 | ``` 170 | 171 | ### Rate-limit routes 172 | ```python 173 | from app.extensions import limiter 174 | 175 | @limiter.limit("5 per minute") 176 | @auth.route('/login', methods=['GET', 'POST']) 177 | def login(): 178 | # ... 179 | return 'your_login_page_contents' 180 | ``` 181 | 182 | ### Automated tests 183 | 184 | Run the test suite: 185 | ```bash 186 | pytest 187 | ``` 188 | 189 | ### Use any relational database using the SQLAlchemy ORM 190 | 191 | ```python 192 | from app.user.models import User 193 | 194 | # Fetch user by id 195 | user = User.get_by_id(id) 196 | 197 | # Save current state of user 198 | user.update() 199 | 200 | # Fetch a paginated set of users 201 | users = User.query.paginate(page, 50) 202 | ``` 203 | 204 | ### Front-end asset management 205 | 206 | Download front-end dependencies with [yarn](https://yarnpkg.com/en/): 207 | ```bash 208 | yarn install --modules-folder ./app/static/node_modules 209 | ``` 210 | 211 | Merge and compress them together with 212 | [Flask-Assets](https://flask-assets.readthedocs.io/en/latest/): 213 | ```bash 214 | flask assets build 215 | ``` 216 | 217 | ### Version your database schema 218 | 219 | Display the current revision: 220 | ```bash 221 | flask db current 222 | ``` 223 | 224 | Create a new migration: 225 | ```bash 226 | flask db revision 227 | ``` 228 | 229 | Upgrade the database to a later version: 230 | ```bash 231 | flask db upgrade 232 | ``` 233 | 234 | ### Internationalize the application for other languages (i18n) 235 | 236 | Extract strings from source and compile a catalog (`.pot`): 237 | ```bash 238 | pybabel extract -F babel.cfg -o i18n/messages.pot . 239 | ``` 240 | 241 | Create a new resource (.po) for German translators: 242 | ```bash 243 | pybabel init -i i18n/messages.pot -d i18n -l de 244 | ``` 245 | 246 | Compile translations (.mo): 247 | ```bash 248 | pybabel compile -d i18n 249 | ``` 250 | 251 | Merge changes into resource files: 252 | ```bash 253 | pybabel update -i i18n/messages.pot -d i18n 254 | ``` 255 | -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from flask import Flask, g, render_template, request 4 | import arrow 5 | import requests 6 | 7 | from app import config 8 | from app.assets import assets 9 | from app.auth import auth 10 | from app.commands import create_db, drop_db, populate_db, recreate_db 11 | from app.database import db 12 | from app.extensions import lm, travis, mail, migrate, bcrypt, babel, rq, limiter 13 | from app.user import user 14 | from app.utils import url_for_other_page 15 | 16 | 17 | def create_app(config=config.base_config): 18 | """Returns an initialized Flask application.""" 19 | app = Flask(__name__) 20 | app.config.from_object(config) 21 | 22 | register_extensions(app) 23 | register_blueprints(app) 24 | register_errorhandlers(app) 25 | register_jinja_env(app) 26 | register_commands(app) 27 | 28 | def get_locale(): 29 | """Returns the locale to be used for the incoming request.""" 30 | return request.accept_languages.best_match(config.SUPPORTED_LOCALES) 31 | 32 | if babel.locale_selector_func is None: 33 | babel.locale_selector_func = get_locale 34 | 35 | @app.before_request 36 | def before_request(): 37 | """Prepare some things before the application handles a request.""" 38 | g.request_start_time = time.time() 39 | g.request_time = lambda: '%.5fs' % (time.time() - g.request_start_time) 40 | g.pjax = 'X-PJAX' in request.headers 41 | 42 | @app.route('/', methods=['GET']) 43 | def index(): 44 | """Returns the applications index page.""" 45 | return render_template('index.html') 46 | 47 | return app 48 | 49 | 50 | def register_commands(app): 51 | """Register custom commands for the Flask CLI.""" 52 | for command in [create_db, drop_db, populate_db, recreate_db]: 53 | app.cli.command()(command) 54 | 55 | 56 | def register_extensions(app): 57 | """Register extensions with the Flask application.""" 58 | travis.init_app(app) 59 | db.init_app(app) 60 | lm.init_app(app) 61 | mail.init_app(app) 62 | bcrypt.init_app(app) 63 | assets.init_app(app) 64 | babel.init_app(app) 65 | rq.init_app(app) 66 | migrate.init_app(app, db) 67 | limiter.init_app(app) 68 | 69 | 70 | def register_blueprints(app): 71 | """Register blueprints with the Flask application.""" 72 | app.register_blueprint(user, url_prefix='/user') 73 | app.register_blueprint(auth) 74 | 75 | 76 | def register_errorhandlers(app): 77 | """Register error handlers with the Flask application.""" 78 | 79 | def render_error(e): 80 | return render_template('errors/%s.html' % e.code), e.code 81 | 82 | for e in [ 83 | requests.codes.INTERNAL_SERVER_ERROR, 84 | requests.codes.NOT_FOUND, 85 | requests.codes.UNAUTHORIZED, 86 | ]: 87 | app.errorhandler(e)(render_error) 88 | 89 | 90 | def register_jinja_env(app): 91 | """Configure the Jinja env to enable some functions in templates.""" 92 | app.jinja_env.globals.update({ 93 | 'timeago': lambda x: arrow.get(x).humanize(), 94 | 'url_for_other_page': url_for_other_page, 95 | }) 96 | -------------------------------------------------------------------------------- /app/assets.py: -------------------------------------------------------------------------------- 1 | from flask_assets import Bundle, Environment, Filter 2 | 3 | class ConcatFilter(Filter): 4 | """ 5 | Filter that merges files, placing a semicolon between them. 6 | 7 | Fixes issues caused by missing semicolons at end of JS assets, for example 8 | with last statement of jquery.pjax.js. 9 | """ 10 | def concat(self, out, hunks, **kw): 11 | out.write(';'.join([h.data() for h, info in hunks])) 12 | 13 | js = Bundle( 14 | 'node_modules/jquery/dist/jquery.js', 15 | 'node_modules/jquery-pjax/jquery.pjax.js', 16 | 'node_modules/bootbox/dist/bootbox.min.js', 17 | 'node_modules/bootstrap/dist/js/bootstrap.min.js', 18 | 'js/application.js', 19 | filters=(ConcatFilter, 'jsmin'), 20 | output='gen/packed.js' 21 | ) 22 | 23 | css = Bundle( 24 | 'node_modules/bootstrap/dist/css/bootstrap.css', 25 | 'node_modules/font-awesome/css/font-awesome.css', 26 | 'css/style.css', 27 | filters=('cssmin','cssrewrite'), 28 | output='gen/packed.css' 29 | ) 30 | 31 | assets = Environment() 32 | assets.register('js_all', js) 33 | assets.register('css_all', css) 34 | -------------------------------------------------------------------------------- /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | auth = Blueprint('auth', __name__, template_folder='templates') 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /app/auth/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from flask_babel import gettext 3 | from wtforms import TextField, PasswordField 4 | from wtforms.validators import DataRequired 5 | 6 | from app.user.models import User 7 | 8 | 9 | class LoginForm(Form): 10 | username = TextField(gettext('Username'), validators=[DataRequired()]) 11 | password = PasswordField(gettext('Password'), validators=[DataRequired()]) 12 | 13 | def __init__(self, *args, **kwargs): 14 | Form.__init__(self, *args, **kwargs) 15 | self.user = None 16 | 17 | def validate(self): 18 | rv = Form.validate(self) 19 | if not rv: 20 | return False 21 | 22 | self.user = User.query.filter_by(username=self.username.data).first() 23 | 24 | if not self.user: 25 | self.username.errors.append(gettext('Unknown username')) 26 | return False 27 | 28 | if not self.user.check_password(self.password.data): 29 | self.password.errors.append(gettext('Invalid password')) 30 | return False 31 | 32 | if not self.user.active: 33 | self.username.errors.append(gettext('User not activated')) 34 | return False 35 | 36 | return True 37 | -------------------------------------------------------------------------------- /app/auth/templates/login.html: -------------------------------------------------------------------------------- 1 | {% from 'components/field.html' import render_field %} 2 | {% extends "layout.html" %} 3 | 4 | {% block title %}{% trans %}Login{% endtrans %}{% endblock %} 5 | 6 | {% block header %} 7 | {% endblock %} 8 | 9 | {% block body %} 10 |
11 |
12 |
13 |

Login

14 |
15 |
16 | {{ form.csrf_token }} 17 | {{ render_field(form.username) }} 18 | {{ render_field(form.password) }} 19 |
20 | 27 |
28 |
29 | {% endblock %} 30 | -------------------------------------------------------------------------------- /app/auth/templates/mail/registration.mail: -------------------------------------------------------------------------------- 1 | Welcome {{ user.username|e}}, 2 | 3 | Please follow the link below to finish your registration. 4 | 5 | {{ url_for('auth.verify', token=token, _external=True) }} 6 | 7 | If you did not initiate this request, you can ignore this email. 8 | -------------------------------------------------------------------------------- /app/auth/templates/register.html: -------------------------------------------------------------------------------- 1 | {% from 'components/field.html' import render_field %} 2 | {% extends "layout.html" %} 3 | 4 | {% block title %}{% trans %}Register{% endtrans %}{% endblock %} 5 | 6 | {% block header %} 7 | {% endblock %} 8 | 9 | {% block body %} 10 |
11 |
12 |
13 |

14 | Register 15 |

16 |
17 |
18 | {{ form.csrf_token }} 19 | {{ render_field(form.username) }} 20 | {{ render_field(form.email) }} 21 | {{ render_field(form.password) }} 22 | {{ render_field(form.confirm) }} 23 |
24 | 28 |
29 |
30 | 35 |
36 |
37 | {% endblock %} 38 | 39 | {% block footer %} 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /app/auth/views.py: -------------------------------------------------------------------------------- 1 | from flask import ( 2 | current_app, request, redirect, url_for, render_template, flash, abort, 3 | ) 4 | from flask_babel import gettext 5 | from flask_login import login_user, login_required, logout_user 6 | from itsdangerous import URLSafeSerializer, BadSignature 7 | from app.extensions import lm 8 | from app.jobs import send_registration_email 9 | from app.user.models import User 10 | from app.user.forms import RegisterUserForm 11 | from .forms import LoginForm 12 | from ..auth import auth 13 | 14 | 15 | @lm.user_loader 16 | def load_user(id): 17 | return User.get_by_id(int(id)) 18 | 19 | 20 | @auth.route('/login', methods=['GET', 'POST']) 21 | def login(): 22 | form = LoginForm() 23 | if form.validate_on_submit(): 24 | login_user(form.user) 25 | flash( 26 | gettext( 27 | 'You were logged in as {username}'.format( 28 | username=form.user.username 29 | ), 30 | ), 31 | 'success' 32 | ) 33 | return redirect(request.args.get('next') or url_for('index')) 34 | return render_template('login.html', form=form) 35 | 36 | 37 | @auth.route('/logout', methods=['GET']) 38 | @login_required 39 | def logout(): 40 | logout_user() 41 | flash(gettext('You were logged out'), 'success') 42 | return redirect(url_for('.login')) 43 | 44 | 45 | @auth.route('/register', methods=['GET', 'POST']) 46 | def register(): 47 | form = RegisterUserForm() 48 | if form.validate_on_submit(): 49 | 50 | user = User.create( 51 | username=form.data['username'], 52 | email=form.data['email'], 53 | password=form.data['password'], 54 | remote_addr=request.remote_addr, 55 | ) 56 | 57 | s = URLSafeSerializer(current_app.secret_key) 58 | token = s.dumps(user.id) 59 | 60 | send_registration_email.queue(user.id, token) 61 | 62 | flash( 63 | gettext( 64 | 'Sent verification email to {email}'.format( 65 | email=user.email 66 | ) 67 | ), 68 | 'success' 69 | ) 70 | return redirect(url_for('index')) 71 | return render_template('register.html', form=form) 72 | 73 | 74 | @auth.route('/verify/', methods=['GET']) 75 | def verify(token): 76 | s = URLSafeSerializer(current_app.secret_key) 77 | try: 78 | id = s.loads(token) 79 | except BadSignature: 80 | abort(404) 81 | 82 | user = User.query.filter_by(id=id).first_or_404() 83 | if user.active: 84 | abort(404) 85 | else: 86 | user.active = True 87 | user.update() 88 | 89 | flash( 90 | gettext( 91 | 'Registered user {username}. Please login to continue.'.format( 92 | username=user.username 93 | ), 94 | ), 95 | 'success' 96 | ) 97 | return redirect(url_for('auth.login')) 98 | -------------------------------------------------------------------------------- /app/commands.py: -------------------------------------------------------------------------------- 1 | from faker import Faker 2 | import click 3 | 4 | from app.database import db 5 | from app.user.models import User 6 | 7 | 8 | @click.option('--num_users', default=5, help='Number of users.') 9 | def populate_db(num_users): 10 | """Populates the database with seed data.""" 11 | fake = Faker() 12 | users = [] 13 | for _ in range(num_users): 14 | users.append( 15 | User( 16 | username=fake.user_name(), 17 | email=fake.email(), 18 | password=fake.word() + fake.word(), 19 | remote_addr=fake.ipv4() 20 | ) 21 | ) 22 | users.append( 23 | User( 24 | username='cburmeister', 25 | email='cburmeister@discogs.com', 26 | password='test123', 27 | remote_addr=fake.ipv4(), 28 | active=True, 29 | is_admin=True 30 | ) 31 | ) 32 | for user in users: 33 | db.session.add(user) 34 | db.session.commit() 35 | 36 | 37 | def create_db(): 38 | """Creates the database.""" 39 | db.create_all() 40 | 41 | 42 | def drop_db(): 43 | """Drops the database.""" 44 | if click.confirm('Are you sure?', abort=True): 45 | db.drop_all() 46 | 47 | 48 | def recreate_db(): 49 | """Same as running drop_db() and create_db().""" 50 | drop_db() 51 | create_db() 52 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class base_config(object): 5 | """Default configuration options.""" 6 | SITE_NAME = os.environ.get('APP_NAME', 'Flask Bones') 7 | 8 | SECRET_KEY = os.environ.get('SECRET_KEY', 'secrets') 9 | SERVER_NAME = os.environ.get('SERVER_NAME', 'app.docker:5000') 10 | 11 | MAIL_SERVER = os.environ.get('MAIL_SERVER', 'mail') 12 | MAIL_PORT = os.environ.get('MAIL_PORT', 1025) 13 | 14 | REDIS_HOST = os.environ.get('REDIS_HOST', 'redis') 15 | REDIS_PORT = os.environ.get('REDIS_PORT', 6379) 16 | RQ_REDIS_URL = 'redis://{}:{}'.format(REDIS_HOST, REDIS_PORT) 17 | 18 | CACHE_HOST = os.environ.get('MEMCACHED_HOST', 'memcached') 19 | CACHE_PORT = os.environ.get('MEMCACHED_PORT', 11211) 20 | 21 | POSTGRES_HOST = os.environ.get('POSTGRES_HOST', 'postgres') 22 | POSTGRES_PORT = os.environ.get('POSTGRES_PORT', 5432) 23 | POSTGRES_USER = os.environ.get('POSTGRES_USER', 'postgres') 24 | POSTGRES_PASS = os.environ.get('POSTGRES_PASS', 'postgres') 25 | POSTGRES_DB = os.environ.get('POSTGRES_DB', 'postgres') 26 | 27 | SQLALCHEMY_DATABASE_URI = 'postgresql://%s:%s@%s:%s/%s' % ( 28 | POSTGRES_USER, 29 | POSTGRES_PASS, 30 | POSTGRES_HOST, 31 | POSTGRES_PORT, 32 | POSTGRES_DB 33 | ) 34 | SQLALCHEMY_TRACK_MODIFICATIONS = False 35 | 36 | SUPPORTED_LOCALES = ['en'] 37 | 38 | 39 | class dev_config(base_config): 40 | """Development configuration options.""" 41 | ASSETS_DEBUG = True 42 | WTF_CSRF_ENABLED = False 43 | 44 | 45 | class test_config(base_config): 46 | """Testing configuration options.""" 47 | TESTING = True 48 | WTF_CSRF_ENABLED = False 49 | -------------------------------------------------------------------------------- /app/database.py: -------------------------------------------------------------------------------- 1 | from flask_sqlalchemy import SQLAlchemy 2 | from sqlalchemy import or_ 3 | 4 | db = SQLAlchemy() 5 | 6 | 7 | class CRUDMixin(object): 8 | __table_args__ = {'extend_existing': True} 9 | 10 | id = db.Column(db.Integer, primary_key=True) 11 | 12 | @classmethod 13 | def get_by_id(cls, id): 14 | if any((isinstance(id, str) and id.isdigit(), 15 | isinstance(id, (int, float))),): 16 | return cls.query.get(int(id)) 17 | return None 18 | 19 | @classmethod 20 | def create(cls, **kwargs): 21 | instance = cls(**kwargs) 22 | return instance.save() 23 | 24 | def update(self, commit=True, **kwargs): 25 | for attr, value in kwargs.items(): 26 | setattr(self, attr, value) 27 | return commit and self.save() or self 28 | 29 | def save(self, commit=True): 30 | db.session.add(self) 31 | if commit: 32 | db.session.commit() 33 | return self 34 | 35 | def delete(self, commit=True): 36 | db.session.delete(self) 37 | return commit and db.session.commit() 38 | 39 | 40 | class DataTable(object): 41 | """ 42 | Represents a sortable, filterable, searchable, and paginated set of data, 43 | generated by arguments in the request values. 44 | 45 | TODO: 46 | - flask-ext for access to request values? 47 | - throw some custom errors when getting fields, etc 48 | - get rid of the 4 helpers that do the same thing 49 | - should this generate some html to help with visualizing the data? 50 | """ 51 | def __init__(self, model, columns, sortable, searchable, filterable, limits, request): 52 | self.model = model 53 | self.query = self.model.query 54 | self.columns = columns 55 | self.sortable = sortable 56 | self.orders = ['asc', 'desc'] 57 | self.searchable = searchable 58 | self.filterable = filterable 59 | self.limits = limits 60 | 61 | self.get_selected(request) 62 | 63 | for f in self.filterable: 64 | self.selected_filter = request.values.get(f.name, None) 65 | self.filter(f.name, self.selected_filter) 66 | self.search(self.selected_query) 67 | self.sort(self.selected_sort, self.selected_order) 68 | self.paginate(self.selected_page, self.selected_limit) 69 | 70 | def get_selected(self, request): 71 | self.selected_sort = request.values.get('sort', self.sortables[0]) 72 | self.selected_order = request.values.get('order', self.orders[0]) 73 | self.selected_query = request.values.get('query', None) 74 | self.selected_limit = request.values.get('limit', self.limits[1], type=int) 75 | self.selected_page = request.values.get('page', 1, type=int) 76 | 77 | @property 78 | def _columns(self): 79 | return [x.name for x in self.columns] 80 | 81 | @property 82 | def sortables(self): 83 | return [x.name for x in self.sortable] 84 | 85 | @property 86 | def searchables(self): 87 | return [x.name for x in self.searchable] 88 | 89 | @property 90 | def filterables(self): 91 | return [x.name for x in self.filterable] 92 | 93 | @property 94 | def colspan(self): 95 | """Length of all columns.""" 96 | return len(self.columns) + len(self.sortable) + len(self.searchable) 97 | 98 | def sort(self, field, order): 99 | """Sorts the data based on a field & order.""" 100 | if field in self.sortables and order in self.orders: 101 | field = getattr(getattr(self.model, field), order) 102 | self.query = self.query.order_by(field()) 103 | 104 | def filter(self, field, value): 105 | """Filters the query based on a field & value.""" 106 | if field and value: 107 | field = getattr(self.model, field) 108 | self.query = self.query.filter(field=value) 109 | 110 | def search(self, search_query): 111 | """Filters the query based on a list of fields & search query.""" 112 | if search_query: 113 | search_query = '%%%s%%' % search_query 114 | fields = [getattr(self.model, x) for x in self.searchables] 115 | self.query = self.query.filter(or_(*[x.like(search_query) for x in fields])) 116 | 117 | def paginate(self, page, limit): 118 | """Paginate the query based on a page & limit.""" 119 | self.query = self.query.paginate(page, limit) 120 | -------------------------------------------------------------------------------- /app/extensions.py: -------------------------------------------------------------------------------- 1 | from flask_assets import Environment 2 | from flask_babel import Babel 3 | from flask_bcrypt import Bcrypt 4 | from flask_limiter import Limiter 5 | from flask_limiter.util import get_remote_address 6 | from flask_login import LoginManager 7 | from flask_mail import Mail 8 | from flask_migrate import Migrate 9 | from flask_rq2 import RQ 10 | from flask_travis import Travis 11 | from werkzeug.contrib.cache import SimpleCache 12 | 13 | assets = Environment() 14 | babel = Babel() 15 | bcrypt = Bcrypt() 16 | cache = SimpleCache() 17 | limiter = Limiter(key_func=get_remote_address) 18 | lm = LoginManager() 19 | mail = Mail() 20 | migrate = Migrate() 21 | rq = RQ() 22 | travis = Travis() 23 | -------------------------------------------------------------------------------- /app/jobs.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | from flask_mail import Message 3 | 4 | from app.extensions import mail, rq 5 | from app.user.models import User 6 | 7 | 8 | @rq.job 9 | def send_registration_email(uid, token): 10 | """Sends a registratiion email to the given uid.""" 11 | user = User.query.filter_by(id=uid).first() 12 | msg = Message( 13 | 'User Registration', 14 | sender='admin@flask-bones.com', 15 | recipients=[user.email] 16 | ) 17 | msg.body = render_template( 18 | 'mail/registration.mail', 19 | user=user, 20 | token=token 21 | ) 22 | mail.send(msg) 23 | -------------------------------------------------------------------------------- /app/static/css/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding-top: 70px; 4 | padding-bottom: 70px; 5 | } 6 | 7 | .container { 8 | max-width: 100%; 9 | } 10 | 11 | .pagination_total { 12 | height: 34px; 13 | position: relative; 14 | top: -12px; 15 | margin-left: 10px; 16 | } 17 | -------------------------------------------------------------------------------- /app/static/js/application.js: -------------------------------------------------------------------------------- 1 | $(function() { 2 | $('.alert').alert() 3 | 4 | $(document).pjax('a[data-pjax]', '#pjax-container'); 5 | 6 | $(document).on('click', 'a[data-confirm], button[data-confirm]', function(event) { 7 | event.preventDefault(); 8 | var $el = $(this); 9 | 10 | bootbox.confirm($el.data('dialogue'), function(result) { 11 | if(result) { 12 | if($el.is('button')) { 13 | $el.closest('form').submit(); 14 | } else { 15 | window.location.href = $el.attr('href'); 16 | } 17 | } 18 | }); 19 | }); 20 | 21 | }); 22 | -------------------------------------------------------------------------------- /app/templates/components/field.html: -------------------------------------------------------------------------------- 1 | {% macro render_field(field) %} 2 | {% with errors = field.errors %} 3 |
4 | {{ field.label(class="control-label") }} 5 | {{ field(class='form-control', **kwargs)|safe }} 6 | {% if errors %} 7 | {% for error in errors %} 8 | {{ error }} 9 | {% endfor %} 10 | {% endif %} 11 |
12 | {% endwith %} 13 | {% endmacro %} 14 | -------------------------------------------------------------------------------- /app/templates/components/flash.html: -------------------------------------------------------------------------------- 1 | {% with messages = get_flashed_messages(with_categories=true) -%} 2 | {% if messages -%} 3 | {% for category, message in messages -%} 4 |
5 | 6 | {{ message }} 7 |
8 | {%- endfor %} 9 | {%- endif %} 10 | {%- endwith %} 11 | -------------------------------------------------------------------------------- /app/templates/components/menu.html: -------------------------------------------------------------------------------- 1 | {% set nav = [ 2 | ('Users', 'user.list'), 3 | ] %} 4 | 5 | 39 | -------------------------------------------------------------------------------- /app/templates/components/pagination.html: -------------------------------------------------------------------------------- 1 | {% macro pagination(pgn, limits=[]) %} 2 |
3 |
4 |
5 | {{ pagination_links(pgn) }} 6 | {{ pagination_total(pgn) }} 7 |
8 |
9 | {% if limits %} 10 | {{ pagination_limit(pgn, limits) }} 11 | {% endif %} 12 |
13 |
14 |
15 | {% endmacro %} 16 | 17 | {% macro pagination_total(pgn) -%} 18 | {% set start = (pgn.page - 1) * pgn.per_page + 1 -%} 19 | 20 | {{ min(pgn.total, start) }} – {{ min(pgn.total, start + pgn.per_page) }} of {{ pgn.total }} 21 | 22 | {%- endmacro %} 23 | 24 | {% macro min(a, b) -%} 25 | {{ a if a < b else b }} 26 | {%- endmacro %} 27 | 28 | {% macro pagination_links(pgn) -%} 29 | 52 | {%- endmacro %} 53 | 54 | {% macro pagination_limit(pgn, limits) -%} 55 |
56 | {% for limit in limits -%} 57 | {% set active = ' active' if pgn.per_page == limit %} 58 | {% set url = url_for(request.endpoint, limit=limit, **request.view_args) %} 59 | {{ limit }} 60 | {%- endfor %} 61 |
62 | {%- endmacro %} 63 | -------------------------------------------------------------------------------- /app/templates/components/search.html: -------------------------------------------------------------------------------- 1 | {% macro search(placeholder) %} 2 |
3 |
4 | 5 | 6 | 7 | 8 | {% set filter_keys = ['limit', 'page', 'query', '_pjax'] %} 9 | {% for key, value in request.values.items(multi=True) if not key in filter_keys %} 10 | 11 | {% endfor %} 12 |
13 |
14 | {% endmacro %} 15 | -------------------------------------------------------------------------------- /app/templates/components/table.html: -------------------------------------------------------------------------------- 1 | {% macro sort_link(sort, selected_sort, order) %} 2 | {% if sort == selected_sort %} 3 | {% if order == 'asc' %} 4 | {{ sort }} 5 | 6 | {% else %} 7 | {{ sort }} 8 | 9 | {% endif %} 10 | {% else %} 11 | {{ sort }} 12 | {% endif %} 13 | {% endmacro %} 14 | 15 | {% macro table_header(datatable, actions=False) %} 16 | 17 | 18 | {% for sort in datatable.sortables %} 19 | {{ sort_link(sort, datatable.selected_sort, datatable.selected_order) }} 20 | {% endfor %} 21 | {% for name in datatable._columns %} 22 | {{ name }} 23 | {% endfor %} 24 | {% if actions %} 25 | 26 | {% endif %} 27 | 28 | 29 | {% endmacro %} 30 | -------------------------------------------------------------------------------- /app/templates/errors/401.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}{% trans %}Unauthorized{% endtrans %}{% endblock %} 4 | 5 | {% block header %} 6 | {% endblock %} 7 | 8 | {% block body %} 9 |

{% trans %}Unauthorized{% endtrans %}

10 |

{% trans %}You're not allowed to do this.{% endtrans %}

11 |

{% trans %}Go somewhere nice{% endtrans %}

12 | {% endblock %} 13 | 14 | {% block footer %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /app/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}Page Not Found{% endblock %} 4 | 5 | {% block header %} 6 | {% endblock %} 7 | 8 | {% block body %} 9 |

{% trans %}Page Not Found{% endtrans %}

10 |

{% trans %}What you were looking for is just not there.{% endtrans %}

11 |

{% trans %}Go somewhere nice{% endtrans %}

12 | {% endblock %} 13 | 14 | {% block footer %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /app/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}{% trans %}Internal Server Error{% endtrans %}{% endblock %} 4 | 5 | {% block header %} 6 | {% endblock %} 7 | 8 | {% block body %} 9 |

{% trans %}Internal Server Error{% endtrans %}

10 |

{% trans %}Something blew up.{% endtrans %}

11 |

{% trans %}Go somewhere nice{% endtrans %}

12 | {% endblock %} 13 | 14 | {% block footer %} 15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %} 4 | {{ config.SITE_NAME }} 5 | {% endblock %} 6 | 7 | {% block header %} 8 | {% endblock %} 9 | 10 | {% block body %} 11 | {% endblock %} 12 | 13 | {% block footer %} 14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /app/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title -%}{%- endblock %} 7 | {% assets "js_all" -%} 8 | 9 | {%- endassets %} 10 | {% assets "css_all" -%} 11 | 12 | {%- endassets %} 13 | 14 | 15 | {% include 'components/menu.html' %} 16 |
17 | {% include 'components/flash.html' %} 18 | {% block header -%}{%- endblock %} 19 | {% block body -%}{%- endblock %} 20 | {% block footer -%}{%- endblock %} 21 |
22 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /app/user/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint 2 | 3 | user = Blueprint('user', __name__, template_folder='templates') 4 | 5 | from . import views 6 | -------------------------------------------------------------------------------- /app/user/forms.py: -------------------------------------------------------------------------------- 1 | from flask_wtf import Form 2 | from flask_babel import gettext 3 | from wtforms import TextField, PasswordField, BooleanField 4 | from wtforms.validators import DataRequired, Email, EqualTo, Length 5 | from app.user.models import User 6 | 7 | 8 | class UserForm(Form): 9 | username = TextField( 10 | gettext('Username'), validators=[DataRequired(), Length(min=2, max=20)] 11 | ) 12 | email = TextField( 13 | gettext('Email'), validators=[Email(), DataRequired(), Length(max=128)] 14 | ) 15 | 16 | def __init__(self, *args, **kwargs): 17 | Form.__init__(self, *args, **kwargs) 18 | 19 | 20 | class RegisterUserForm(UserForm): 21 | password = PasswordField( 22 | gettext('Password'), 23 | validators=[ 24 | DataRequired(), 25 | EqualTo( 26 | 'confirm', 27 | message=gettext('Passwords must match') 28 | ), 29 | Length(min=6, max=20) 30 | ] 31 | ) 32 | confirm = PasswordField( 33 | gettext('Confirm Password'), validators=[DataRequired()] 34 | ) 35 | accept_tos = BooleanField( 36 | gettext('I accept the TOS'), validators=[DataRequired()] 37 | ) 38 | 39 | def __init__(self, *args, **kwargs): 40 | Form.__init__(self, *args, **kwargs) 41 | self.user = None 42 | 43 | def validate(self): 44 | rv = Form.validate(self) 45 | if not rv: 46 | return False 47 | 48 | user = User.query.filter_by(username=self.username.data).first() 49 | if user: 50 | self.username.errors.append(gettext('Username already registered')) 51 | return False 52 | 53 | user = User.query.filter_by(email=self.email.data).first() 54 | if user: 55 | self.email.errors.append(gettext('Email already registered')) 56 | return False 57 | 58 | self.user = user 59 | return True 60 | 61 | 62 | class EditUserForm(UserForm): 63 | is_admin = BooleanField(gettext('Admin')) 64 | active = BooleanField(gettext('Activated')) 65 | -------------------------------------------------------------------------------- /app/user/models.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from flask_login import UserMixin 4 | 5 | from app.database import db, CRUDMixin 6 | from app.extensions import bcrypt 7 | 8 | 9 | class User(CRUDMixin, UserMixin, db.Model): 10 | id = db.Column(db.Integer, primary_key=True) 11 | username = db.Column(db.String(20), nullable=False, unique=True) 12 | email = db.Column(db.String(128), nullable=False, unique=True) 13 | pw_hash = db.Column(db.String(60), nullable=False) 14 | created_ts = db.Column( 15 | db.DateTime(timezone=True), 16 | default=datetime.datetime.utcnow 17 | ) 18 | updated_ts = db.Column( 19 | db.DateTime(timezone=True), 20 | default=datetime.datetime.utcnow 21 | ) 22 | remote_addr = db.Column(db.String(20)) 23 | active = db.Column(db.Boolean()) 24 | is_admin = db.Column(db.Boolean()) 25 | 26 | def __init__(self, password, **kwargs): 27 | super(User, self).__init__(**kwargs) 28 | self.set_password(password) 29 | 30 | def __repr__(self): 31 | return '' % (self.id, self.username) 32 | 33 | def set_password(self, password): 34 | hash_ = bcrypt.generate_password_hash(password, 10).decode('utf-8') 35 | self.pw_hash = hash_ 36 | 37 | def check_password(self, password): 38 | return bcrypt.check_password_hash(self.pw_hash, password) 39 | -------------------------------------------------------------------------------- /app/user/templates/edit.html: -------------------------------------------------------------------------------- 1 | {% from 'components/field.html' import render_field %} 2 | 3 | {% extends "layout.html" %} 4 | 5 | {% block title %} 6 | Edit User - {{ user.username }} 7 | {% endblock %} 8 | 9 | {% block header %} 10 | 15 | {% endblock %} 16 | 17 | {% block body %} 18 |
19 |
20 |
21 | Edit User 22 |
23 |
24 | {{ form.csrf_token }} 25 | {{ render_field(form.username) }} 26 | {{ render_field(form.email) }} 27 |
28 | 32 |
33 |
34 | 38 |
39 |
40 | 50 |
51 |
52 | {% endblock %} 53 | 54 | {% block footer %} 55 | {% endblock %} 56 | -------------------------------------------------------------------------------- /app/user/templates/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %} 4 | Users 5 | {% endblock %} 6 | 7 | {% block header %} 8 | 12 | {% endblock %} 13 | 14 | {% block body %} 15 |
16 | {% include 'users.html' %} 17 |
18 | {% endblock %} 19 | 20 | {% block footer %} 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /app/user/templates/users.html: -------------------------------------------------------------------------------- 1 | {% from 'components/table.html' import table_header %} 2 | {% from 'components/pagination.html' import pagination %} 3 | {% from 'components/search.html' import search %} 4 | 5 |
6 |
7 | {{ search('Search for a Username or Email') }} 8 |
9 |
10 | 11 | {{ table_header(datatable, actions=True) }} 12 | 13 | {% for u in datatable.query.items %} 14 | 15 | 16 | 17 | 18 | 19 | 25 | 26 | {% else %} 27 | 28 | {% endfor %} 29 | 30 |
{{ u.username }}{{ u.email }}{{ timeago(u.created_ts) }}{{ u.remote_addr }} 20 | {% set dialogue = 'Are you sure you want to delete %s?' % u.username %} 21 | 22 | 23 | 24 |
No results found for: {{ request.values.get('query') }}
31 |
32 | 35 |
36 | -------------------------------------------------------------------------------- /app/user/views.py: -------------------------------------------------------------------------------- 1 | from flask import request, redirect, url_for, render_template, flash, g 2 | from flask_babel import gettext 3 | from flask_login import login_required 4 | from app.user.models import User 5 | from .forms import EditUserForm 6 | 7 | from ..user import user 8 | 9 | 10 | @user.route('/list', methods=['GET', 'POST']) 11 | @login_required 12 | def list(): 13 | 14 | from app.database import DataTable 15 | datatable = DataTable( 16 | model=User, 17 | columns=[User.remote_addr], 18 | sortable=[User.username, User.email, User.created_ts], 19 | searchable=[User.username, User.email], 20 | filterable=[User.active], 21 | limits=[25, 50, 100], 22 | request=request 23 | ) 24 | 25 | if g.pjax: 26 | return render_template('users.html', datatable=datatable) 27 | 28 | return render_template('list.html', datatable=datatable) 29 | 30 | 31 | @user.route('/edit/', methods=['GET', 'POST']) 32 | @login_required 33 | def edit(id): 34 | user = User.query.filter_by(id=id).first_or_404() 35 | form = EditUserForm(obj=user) 36 | if form.validate_on_submit(): 37 | form.populate_obj(user) 38 | user.update() 39 | flash( 40 | gettext('User {username} edited'.format(username=user.username)), 41 | 'success' 42 | ) 43 | return render_template('edit.html', form=form, user=user) 44 | 45 | 46 | @user.route('/delete/', methods=['GET']) 47 | @login_required 48 | def delete(id): 49 | user = User.query.filter_by(id=id).first_or_404() 50 | user.delete() 51 | flash( 52 | gettext('User {username} deleted').format(username=user.username), 53 | 'success' 54 | ) 55 | return redirect(url_for('.list')) 56 | -------------------------------------------------------------------------------- /app/utils.py: -------------------------------------------------------------------------------- 1 | from flask import request, url_for 2 | 3 | 4 | def url_for_other_page(**kwargs): 5 | """Returns a URL aimed at the current request endpoint and query args.""" 6 | url_for_args = request.args.copy() 7 | if 'pjax' in url_for_args: 8 | url_for_args.pop('_pjax') 9 | for key, value in kwargs.items(): 10 | url_for_args[key] = value 11 | return url_for(request.endpoint, **url_for_args) 12 | -------------------------------------------------------------------------------- /babel.cfg: -------------------------------------------------------------------------------- 1 | [python: **.py] 2 | [jinja2: **/templates/**.html] 3 | extensions=jinja2.ext.autoescape,jinja2.ext.with_,webassets.ext.jinja2.AssetsExtension 4 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | build: . 5 | command: python -m flask run --host 0.0.0.0 --port 5000 6 | environment: 7 | - FLASK_APP=serve.py 8 | - FLASK_ENV=development 9 | ports: 10 | - "5000:5000" 11 | volumes: 12 | - .:/var/www/flask-bones 13 | worker: 14 | build: . 15 | command: python -m flask rq worker 16 | environment: 17 | - FLASK_APP=serve.py 18 | volumes: 19 | - .:/var/www/flask-bones 20 | mail: 21 | image: "mailhog/mailhog" 22 | ports: 23 | - "8025:8025" 24 | memcached: 25 | image: "memcached:alpine" 26 | postgres: 27 | image: "postgres:alpine" 28 | redis: 29 | image: "redis:alpine" 30 | -------------------------------------------------------------------------------- /i18n/messages.pot: -------------------------------------------------------------------------------- 1 | # Translations template for PROJECT. 2 | # Copyright (C) 2015 ORGANIZATION 3 | # This file is distributed under the same license as the PROJECT project. 4 | # FIRST AUTHOR , 2015. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PROJECT VERSION\n" 10 | "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" 11 | "POT-Creation-Date: 2015-11-24 07:36+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=utf-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Generated-By: Babel 2.1.1\n" 19 | 20 | #: app/auth/forms.py:10 app/user/forms.py:10 21 | msgid "Username" 22 | msgstr "" 23 | 24 | #: app/auth/forms.py:11 app/user/forms.py:22 25 | msgid "Password" 26 | msgstr "" 27 | 28 | #: app/auth/forms.py:25 29 | msgid "Unknown username" 30 | msgstr "" 31 | 32 | #: app/auth/forms.py:29 33 | msgid "Invalid password" 34 | msgstr "" 35 | 36 | #: app/auth/forms.py:33 37 | msgid "User not activated" 38 | msgstr "" 39 | 40 | #: app/auth/views.py:27 41 | msgid "You were logged in as {username}" 42 | msgstr "" 43 | 44 | #: app/auth/views.py:41 45 | msgid "You were logged out" 46 | msgstr "" 47 | 48 | #: app/auth/views.py:64 49 | msgid "Sent verification email to {email}" 50 | msgstr "" 51 | 52 | #: app/auth/views.py:91 53 | msgid "Registered user {username}. Please login to continue." 54 | msgstr "" 55 | 56 | #: app/auth/templates/login.html:4 app/auth/templates/login.html:22 57 | msgid "Login" 58 | msgstr "" 59 | 60 | #: app/auth/templates/login.html:24 61 | msgid "or" 62 | msgstr "" 63 | 64 | #: app/auth/templates/login.html:25 app/auth/templates/register.html:4 65 | msgid "Register" 66 | msgstr "" 67 | 68 | #: app/auth/templates/register.html:32 69 | msgid "Submit" 70 | msgstr "" 71 | 72 | #: app/templates/layout.html:24 73 | msgid "Terms" 74 | msgstr "" 75 | 76 | #: app/templates/layout.html:25 77 | msgid "Privacy" 78 | msgstr "" 79 | 80 | #: app/templates/layout.html:26 81 | msgid "Security" 82 | msgstr "" 83 | 84 | #: app/templates/errors/401.html:3 app/templates/errors/401.html:9 85 | msgid "Unauthorized" 86 | msgstr "" 87 | 88 | #: app/templates/errors/401.html:10 89 | msgid "You're not allowed to do this." 90 | msgstr "" 91 | 92 | #: app/templates/errors/401.html:11 app/templates/errors/404.html:11 93 | #: app/templates/errors/500.html:11 94 | msgid "Go somewhere nice" 95 | msgstr "" 96 | 97 | #: app/templates/errors/404.html:9 98 | msgid "Page Not Found" 99 | msgstr "" 100 | 101 | #: app/templates/errors/404.html:10 102 | msgid "What you were looking for is just not there." 103 | msgstr "" 104 | 105 | #: app/templates/errors/500.html:3 app/templates/errors/500.html:9 106 | msgid "Internal Server Error" 107 | msgstr "" 108 | 109 | #: app/templates/errors/500.html:10 110 | msgid "Something blew up." 111 | msgstr "" 112 | 113 | #: app/user/forms.py:13 114 | msgid "Email" 115 | msgstr "" 116 | 117 | #: app/user/forms.py:27 118 | msgid "Passwords must match" 119 | msgstr "" 120 | 121 | #: app/user/forms.py:33 122 | msgid "Confirm Password" 123 | msgstr "" 124 | 125 | #: app/user/forms.py:36 126 | msgid "I accept the TOS" 127 | msgstr "" 128 | 129 | #: app/user/forms.py:50 130 | msgid "Username already registered" 131 | msgstr "" 132 | 133 | #: app/user/forms.py:55 134 | msgid "Email already registered" 135 | msgstr "" 136 | 137 | #: app/user/forms.py:63 138 | msgid "Admin" 139 | msgstr "" 140 | 141 | #: app/user/forms.py:64 142 | msgid "Activated" 143 | msgstr "" 144 | 145 | #: app/user/views.py:48 146 | msgid "User {username} edited" 147 | msgstr "" 148 | 149 | #: app/user/views.py:60 150 | msgid "User {username} deleted" 151 | msgstr "" 152 | 153 | -------------------------------------------------------------------------------- /image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cburmeister/flask-bones/bc61f5c12b50e366df605462ba227f5efc03bc1b/image.jpg -------------------------------------------------------------------------------- /migrations/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration. -------------------------------------------------------------------------------- /migrations/alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # template used to generate migration files 5 | # file_template = %%(rev)s_%%(slug)s 6 | 7 | # set to 'true' to run the environment during 8 | # the 'revision' command, regardless of autogenerate 9 | # revision_environment = false 10 | 11 | 12 | # Logging configuration 13 | [loggers] 14 | keys = root,sqlalchemy,alembic 15 | 16 | [handlers] 17 | keys = console 18 | 19 | [formatters] 20 | keys = generic 21 | 22 | [logger_root] 23 | level = WARN 24 | handlers = console 25 | qualname = 26 | 27 | [logger_sqlalchemy] 28 | level = WARN 29 | handlers = 30 | qualname = sqlalchemy.engine 31 | 32 | [logger_alembic] 33 | level = INFO 34 | handlers = 35 | qualname = alembic 36 | 37 | [handler_console] 38 | class = StreamHandler 39 | args = (sys.stderr,) 40 | level = NOTSET 41 | formatter = generic 42 | 43 | [formatter_generic] 44 | format = %(levelname)-5.5s [%(name)s] %(message)s 45 | datefmt = %H:%M:%S 46 | -------------------------------------------------------------------------------- /migrations/env.py: -------------------------------------------------------------------------------- 1 | from __future__ import with_statement 2 | from alembic import context 3 | from sqlalchemy import engine_from_config, pool 4 | from logging.config import fileConfig 5 | import logging 6 | 7 | # this is the Alembic Config object, which provides 8 | # access to the values within the .ini file in use. 9 | config = context.config 10 | 11 | # Interpret the config file for Python logging. 12 | # This line sets up loggers basically. 13 | fileConfig(config.config_file_name) 14 | logger = logging.getLogger('alembic.env') 15 | 16 | # add your model's MetaData object here 17 | # for 'autogenerate' support 18 | # from myapp import mymodel 19 | # target_metadata = mymodel.Base.metadata 20 | from flask import current_app 21 | config.set_main_option('sqlalchemy.url', 22 | current_app.config.get('SQLALCHEMY_DATABASE_URI')) 23 | target_metadata = current_app.extensions['migrate'].db.metadata 24 | 25 | # other values from the config, defined by the needs of env.py, 26 | # can be acquired: 27 | # my_important_option = config.get_main_option("my_important_option") 28 | # ... etc. 29 | 30 | 31 | def run_migrations_offline(): 32 | """Run migrations in 'offline' mode. 33 | 34 | This configures the context with just a URL 35 | and not an Engine, though an Engine is acceptable 36 | here as well. By skipping the Engine creation 37 | we don't even need a DBAPI to be available. 38 | 39 | Calls to context.execute() here emit the given string to the 40 | script output. 41 | 42 | """ 43 | url = config.get_main_option("sqlalchemy.url") 44 | context.configure(url=url) 45 | 46 | with context.begin_transaction(): 47 | context.run_migrations() 48 | 49 | 50 | def run_migrations_online(): 51 | """Run migrations in 'online' mode. 52 | 53 | In this scenario we need to create an Engine 54 | and associate a connection with the context. 55 | 56 | """ 57 | 58 | # this callback is used to prevent an auto-migration from being generated 59 | # when there are no changes to the schema 60 | # reference: http://alembic.readthedocs.org/en/latest/cookbook.html 61 | def process_revision_directives(context, revision, directives): 62 | if getattr(config.cmd_opts, 'autogenerate', False): 63 | script = directives[0] 64 | if script.upgrade_ops.is_empty(): 65 | directives[:] = [] 66 | logger.info('No changes in schema detected.') 67 | 68 | engine = engine_from_config(config.get_section(config.config_ini_section), 69 | prefix='sqlalchemy.', 70 | poolclass=pool.NullPool) 71 | 72 | connection = engine.connect() 73 | context.configure(connection=connection, 74 | target_metadata=target_metadata, 75 | process_revision_directives=process_revision_directives, 76 | **current_app.extensions['migrate'].configure_args) 77 | 78 | try: 79 | with context.begin_transaction(): 80 | context.run_migrations() 81 | finally: 82 | connection.close() 83 | 84 | if context.is_offline_mode(): 85 | run_migrations_offline() 86 | else: 87 | run_migrations_online() 88 | -------------------------------------------------------------------------------- /migrations/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = ${repr(up_revision)} 11 | down_revision = ${repr(down_revision)} 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | ${imports if imports else ""} 16 | 17 | def upgrade(): 18 | ${upgrades if upgrades else "pass"} 19 | 20 | 21 | def downgrade(): 22 | ${downgrades if downgrades else "pass"} 23 | -------------------------------------------------------------------------------- /migrations/versions/1fb7c6da302_.py: -------------------------------------------------------------------------------- 1 | """Initial migration 2 | 3 | Revision ID: 1fb7c6da302 4 | Revises: None 5 | Create Date: 2015-11-16 17:19:00.332397 6 | 7 | """ 8 | 9 | # revision identifiers, used by Alembic. 10 | revision = '1fb7c6da302' 11 | down_revision = None 12 | 13 | from alembic import op 14 | import sqlalchemy as sa 15 | 16 | 17 | def upgrade(): 18 | op.create_table('user', 19 | sa.Column('id', sa.Integer(), nullable=False), 20 | sa.Column('username', sa.String(length=20), nullable=False), 21 | sa.Column('email', sa.String(length=128), nullable=False), 22 | sa.Column('pw_hash', sa.String(length=60), nullable=False), 23 | sa.Column('created_ts', sa.DateTime(), nullable=False), 24 | sa.Column('remote_addr', sa.String(length=20), nullable=True), 25 | sa.Column('active', sa.Boolean(), nullable=True), 26 | sa.Column('is_admin', sa.Boolean(), nullable=True), 27 | sa.PrimaryKeyConstraint('id'), 28 | sa.UniqueConstraint('email'), 29 | sa.UniqueConstraint('username') 30 | ) 31 | 32 | 33 | def downgrade(): 34 | op.drop_table('user') 35 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flask-bones", 3 | "version": "1.0.0", 4 | "description": "An example of a large scale Flask application using blueprints and extensions.", 5 | "main": "index.js", 6 | "repository": "https://github.com/cburmeister/flask-bones.git", 7 | "author": "Corey Burmeister ", 8 | "license": "MIT", 9 | "dependencies": { 10 | "bootbox": "5.3.2", 11 | "bootstrap": "4.3.1", 12 | "font-awesome": "4.7.0", 13 | "jquery": "3.4.1", 14 | "jquery-pjax": "2.0.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = tests.py 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Faker==2.0.2 2 | Flask-Assets==0.12 3 | Flask-Babel==0.12.2 4 | Flask-Bcrypt==0.7.1 5 | Flask-Limiter==1.1.0 6 | Flask-Login==0.4.1 7 | Flask-Mail==0.9.1 8 | Flask-Migrate==2.5.2 9 | Flask-RQ2==18.3 10 | Flask-SQLAlchemy==2.4.1 11 | Flask-Script==2.0.6 12 | Flask-Travis==0.0.2 13 | Flask-WTF==0.14.2 14 | Flask==1.1.1 15 | Jinja2==2.10.2 16 | MarkupSafe==1.1.1 17 | SQLAlchemy==1.3.11 18 | WTForms==2.2.1 19 | Werkzeug==0.16.0 20 | arrow==0.15.2 21 | blinker==1.4 22 | cssmin==0.2.0 23 | gunicorn==20.0.4 24 | itsdangerous==1.1.0 25 | jsmin==2.2.2 26 | psycopg2==2.8.3 27 | pytest==5.3.1 28 | python-dateutil==2.8.0 29 | redis==3.3.8 30 | requests==2.22.0 31 | -------------------------------------------------------------------------------- /serve.py: -------------------------------------------------------------------------------- 1 | from app import create_app, config 2 | 3 | app = create_app(config.dev_config) 4 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | from app.config import test_config 3 | from app.database import db 4 | from app.user.models import User 5 | from sqlalchemy.sql.expression import func 6 | from faker import Faker 7 | import unittest 8 | 9 | admin_username = 'cburmeister' 10 | admin_email = 'cburmeister@discogs.com' 11 | admin_password = 'test123' 12 | 13 | fake = Faker() 14 | 15 | 16 | class TestCase(unittest.TestCase): 17 | def setUp(self): 18 | app = create_app(test_config) 19 | db.app = app # hack for using db.init_app(app) in app/__init__.py 20 | self.app = app.test_client() 21 | 22 | def tearDown(self): 23 | pass 24 | 25 | def login(self, username, password): 26 | return self.app.post('/login', data=dict( 27 | username=username, 28 | password=password 29 | ), follow_redirects=True) 30 | 31 | def register_user(self, username, email, password): 32 | return self.app.post('/register', data=dict( 33 | username=username, 34 | email=email, 35 | password=password, 36 | confirm=password, 37 | accept_tos=True 38 | ), follow_redirects=True) 39 | 40 | def edit_user(self, user, email): 41 | return self.app.post('/user/edit/%s' % user.id, data=dict( 42 | username=user.username, 43 | email=user.email, 44 | ), follow_redirects=True) 45 | 46 | def delete_user(self, uid): 47 | return self.app.get('/user/delete/%s' % uid, follow_redirects=True) 48 | 49 | def test_404(self): 50 | resp = self.app.get('/nope', follow_redirects=True) 51 | assert resp.data, 'Page Not Found' 52 | 53 | def test_index(self): 54 | resp = self.app.get('/index', follow_redirects=True) 55 | assert resp.data, 'Flask Bones' 56 | 57 | def test_login(self): 58 | resp = self.login(admin_username, admin_password) 59 | assert resp.data, 'You were logged in' 60 | 61 | def test_logout(self): 62 | resp = self.login(admin_username, admin_password) 63 | resp = self.app.get('/logout', follow_redirects=True) 64 | assert resp.data, 'You were logged out' 65 | 66 | def test_register_user(self): 67 | username = fake.user_name() 68 | email = fake.email() 69 | password = fake.word() + fake.word() 70 | resp = self.register_user(username, email, password) 71 | assert resp.data, 'Sent verification email to %s' % email 72 | 73 | def test_edit_user(self): 74 | user = User.query.order_by(func.random()).first() 75 | resp = self.login(admin_username, admin_password) 76 | resp = self.edit_user(user, email=fake.email()) 77 | assert resp.data, 'User %s edited' % user.username 78 | 79 | def test_delete_user(self): 80 | user = User.query.order_by(func.random()).first() 81 | resp = self.login(admin_username, admin_password) 82 | resp = self.delete_user(user.id) 83 | assert resp.data, 'User %s deleted' % user.username 84 | 85 | def test_user_list(self): 86 | resp = self.login(admin_username, admin_password) 87 | resp = self.app.get('/user/list', follow_redirects=True) 88 | assert resp.data, 'Users' 89 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | bootbox@5.3.2: 6 | version "5.3.2" 7 | resolved "https://registry.yarnpkg.com/bootbox/-/bootbox-5.3.2.tgz#f3117d80d70f4c94f1ea7724a687775fb9aef71a" 8 | dependencies: 9 | bootstrap ">=3.0.0" 10 | jquery ">=1.12.0" 11 | popper.js ">=1.12.9" 12 | 13 | bootstrap@4.3.1, bootstrap@>=3.0.0: 14 | version "4.3.1" 15 | resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.3.1.tgz#280ca8f610504d99d7b6b4bfc4b68cec601704ac" 16 | 17 | font-awesome@4.7.0: 18 | version "4.7.0" 19 | resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" 20 | 21 | jquery-pjax@2.0.1: 22 | version "2.0.1" 23 | resolved "https://registry.yarnpkg.com/jquery-pjax/-/jquery-pjax-2.0.1.tgz#6b3a1ba16e644e624bdcfe72eb6b3d96a846f5f2" 24 | 25 | jquery@3.4.1, jquery@>=1.12.0: 26 | version "3.4.1" 27 | resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.4.1.tgz#714f1f8d9dde4bdfa55764ba37ef214630d80ef2" 28 | 29 | popper.js@>=1.12.9: 30 | version "1.15.0" 31 | resolved "https://registry.yarnpkg.com/popper.js/-/popper.js-1.15.0.tgz#5560b99bbad7647e9faa475c6b8056621f5a4ff2" 32 | --------------------------------------------------------------------------------