commit 3b840a840c721bfa2ad7b30590c778bc3ffdd8b0 Author: brettlangdon Date: Wed Jul 27 21:45:46 2016 -0400 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f09542d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +aws_reboot/static/.webassets-cache +aws_reboot/static/dist +node_modules diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..93fdf9f --- /dev/null +++ b/README.rst @@ -0,0 +1,26 @@ +cookiecutter-flask-app +====================== + +My own personal `cookiecutter `_ template for writing `Flask `_ apps. + +This cookiecutter sets up a starter Flask app using: + +* `Flask `_ +* `Flask-Assets `_ +* `Flask-Migrate `_ +* `Flask-Script `_ +* `Flask-Security `_ +* `Flask-SQLAlchemy `_ +* `libsass-python `_ +* `webassets-browserify `_ + + +Usage +----- + +To create a new starting project: + +.. code-block:: bash + + pip install cookiecutter + cookiecutter https://github.com/brettlangdon/cookiecutter-flask-app.git diff --git a/cookiecutter.json b/cookiecutter.json new file mode 100644 index 0000000..8511daf --- /dev/null +++ b/cookiecutter.json @@ -0,0 +1,7 @@ +{ + "project_name": "flask_app", + "database_uri": "postgresql://localhost/{{cookiecutter.project_name}}", + "_copy_without_render": [ + "*.jinja" + ] +} diff --git a/{{cookiecutter.project_name}}/README.rst b/{{cookiecutter.project_name}}/README.rst new file mode 100644 index 0000000..12478e6 --- /dev/null +++ b/{{cookiecutter.project_name}}/README.rst @@ -0,0 +1,2 @@ +{{cookiecutter.project_name}} +=== diff --git a/{{cookiecutter.project_name}}/bin/{{cookiecutter.project_name}} b/{{cookiecutter.project_name}}/bin/{{cookiecutter.project_name}} new file mode 100755 index 0000000..989c57a --- /dev/null +++ b/{{cookiecutter.project_name}}/bin/{{cookiecutter.project_name}} @@ -0,0 +1,3 @@ +#!/usr/bin/env python +from {{cookiecutter.project_name}}.manager import manager +manager.run() diff --git a/{{cookiecutter.project_name}}/migrations/alembic.ini b/{{cookiecutter.project_name}}/migrations/alembic.ini new file mode 100644 index 0000000..f8ed480 --- /dev/null +++ b/{{cookiecutter.project_name}}/migrations/alembic.ini @@ -0,0 +1,45 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/{{cookiecutter.project_name}}/migrations/env.py b/{{cookiecutter.project_name}}/migrations/env.py new file mode 100755 index 0000000..4593816 --- /dev/null +++ b/{{cookiecutter.project_name}}/migrations/env.py @@ -0,0 +1,87 @@ +from __future__ import with_statement +from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging + +# 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) + + 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.readthedocs.org/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.') + + engine = engine_from_config(config.get_section(config.config_ini_section), + prefix='sqlalchemy.', + poolclass=pool.NullPool) + + connection = engine.connect() + context.configure(connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + **current_app.extensions['migrate'].configure_args) + + try: + with context.begin_transaction(): + context.run_migrations() + finally: + connection.close() + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/{{cookiecutter.project_name}}/migrations/script.py.mako b/{{cookiecutter.project_name}}/migrations/script.py.mako new file mode 100755 index 0000000..9570201 --- /dev/null +++ b/{{cookiecutter.project_name}}/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision} +Create Date: ${create_date} + +""" + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/{{cookiecutter.project_name}}/migrations/versions/70bd704dcb5c_setup_user_and_role_tables.py b/{{cookiecutter.project_name}}/migrations/versions/70bd704dcb5c_setup_user_and_role_tables.py new file mode 100644 index 0000000..c6ebedb --- /dev/null +++ b/{{cookiecutter.project_name}}/migrations/versions/70bd704dcb5c_setup_user_and_role_tables.py @@ -0,0 +1,49 @@ +"""Setup user and role tables + +Revision ID: 70bd704dcb5c +Revises: None +Create Date: 2016-07-26 10:07:41.130632 + +""" + +# revision identifiers, used by Alembic. +revision = '70bd704dcb5c' +down_revision = None + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +def upgrade(): + op.create_table( + 'role', + sa.Column('id', UUID(), nullable=False), + sa.Column('name', sa.String(length=80), nullable=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name'), + ) + op.create_table( + 'user', + sa.Column('id', UUID(), nullable=False), + sa.Column('email', sa.String(length=255), nullable=True), + sa.Column('password', sa.String(length=255), nullable=True), + sa.Column('active', sa.Boolean(), nullable=True), + sa.Column('confirmed_at', sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + ) + op.create_table( + 'user_role', + sa.Column('user_id', UUID(), nullable=True), + sa.Column('role_id', UUID(), nullable=True), + sa.ForeignKeyConstraint(['role_id'], ['role.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + ) + + +def downgrade(): + op.drop_table('user_role') + op.drop_table('user') + op.drop_table('role') diff --git a/{{cookiecutter.project_name}}/package.json b/{{cookiecutter.project_name}}/package.json new file mode 100644 index 0000000..1c24d93 --- /dev/null +++ b/{{cookiecutter.project_name}}/package.json @@ -0,0 +1,8 @@ +{ + "name": "{{cookiecutter.project_name}}", + "private": true, + "dependencies": { + "browserify": "^13.1.0", + "bulma": "^0.1.0" + } +} diff --git a/{{cookiecutter.project_name}}/requirements-dev.txt b/{{cookiecutter.project_name}}/requirements-dev.txt new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_name}}/requirements.txt b/{{cookiecutter.project_name}}/requirements.txt new file mode 100644 index 0000000..6f13b39 --- /dev/null +++ b/{{cookiecutter.project_name}}/requirements.txt @@ -0,0 +1,10 @@ +Flask==0.11.1 +# Waiting on next release for a fix for libsass +-e git+https://github.com/miracle2k/flask-assets@36da5c90cf99293ac632cfab58db52cab1c8cc83#egg=flask_assets +Flask-Migrate==1.8.1 +Flask-Script==2.0.5 +Flask-Security==1.7.5 +Flask-SQLAlchemy==2.1 +libsass==0.9.3 +psycopg2==2.6.2 +webassets-browserify==1.1.0 diff --git a/{{cookiecutter.project_name}}/setup.py b/{{cookiecutter.project_name}}/setup.py new file mode 100644 index 0000000..48878c1 --- /dev/null +++ b/{{cookiecutter.project_name}}/setup.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +from setuptools import setup + +with open('README.rst') as readme_file: + readme = readme_file.read() + +requirements = [] +with open('requirements.txt') as requirements_file: + requirements = [l.strip('\n') for l in requirements_file] + + +test_requirements = [] +with open('requirements-dev.txt') as test_requirements_file: + test_requirements = [l for l in test_requirements_file] + + +setup( + name='{{cookiecutter.project_name}}', + version='0.1.0', + long_description=readme, + packages=[ + '{{cookiecutter.project_name}}', + ], + package_dir={ + '{{cookiecutter.project_name}}': '{{cookiecutter.project_name}}' + }, + scripts=[ + 'bin/{{cookiecutter.project_name}}', + ], + include_package_data=True, + install_requires=requirements, + zip_safe=False, + test_suite='tests', + tests_require=test_requirements, +) diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__init__.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__init__.py new file mode 100644 index 0000000..d2a09a2 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__init__.py @@ -0,0 +1,30 @@ +from flask import Flask +from flask_assets import Bundle, Environment +from flask_security import Security, SQLAlchemyUserDatastore +from flask_sqlalchemy import SQLAlchemy +from webassets.filter import register_filter +from webassets_browserify import Browserify + +app = Flask(__name__) +app.config.from_object('{{cookiecutter.project_name}}.configuration.EnvConfig') + +db = SQLAlchemy(app) + +# Configure static assets +assets = Environment(app) + +css = Bundle('scss/app.scss', filters='libsass', output='dist/app.css', depends=['scss/**/*.scss']) +assets.register('css_all', css) + +register_filter(Browserify) +js = Bundle('js/app.js', filters='browserify', output='dist/app.js', depends=['js/**/*.js']) +assets.register('js_all', js) + +# Import models down here to ensure circular import works as expected +from .models.role import Role +from .models.user import User +user_datastore = SQLAlchemyUserDatastore(db, User, Role) +security = Security(app, user_datastore) + +from .views import bind_routes +bind_routes(app) diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py new file mode 100644 index 0000000..e9ecc21 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py @@ -0,0 +1,2 @@ +from {{cookiecutter.project_name}}.manager import manager +manager.run() diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/configuration.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/configuration.py new file mode 100644 index 0000000..65e2bad --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/configuration.py @@ -0,0 +1,38 @@ +import json +import os + +HERE = os.path.abspath(os.path.dirname(__file__)) +ROOT = os.path.abspath(os.path.join(HERE, '..')) +NODE_MODULES = os.path.abspath(os.path.join(ROOT, 'node_modules')) + + +class MetaEnvConfig(type): + def __init__(cls, name, bases, dict): + super(MetaEnvConfig, cls).__init__(name, bases, dict) + + # Override default configuration from environment variables + for key, value in os.environ.items(): + try: + value = json.loads(value) + except Exception: + pass + setattr(cls, key, value) + + +class EnvConfig(metaclass=MetaEnvConfig): + # Assets + BROWSERIFY_BIN = os.path.abspath(os.path.join(NODE_MODULES, '.bin', 'browserify')) + LIBSASS_INCLUDES = [os.path.abspath(os.path.join(NODE_MODULES, 'bulma'))] + LIBSASS_STYLE = 'compressed' + + # User sessions + SECRET_KEY = 'super secret key' + SECURITY_PASSWORD_HASH = 'pbkdf2_sha512' + SECURITY_PASSWORD_SALT = 'super secret salt' + + # User session templates + SECURITY_LOGIN_USER_TEMPLATE = 'security/login_user.jinja' + + # Database + SQLALCHEMY_DATABASE_URI = '{{cookiecutter.database_uri}}' + SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/forms/__init__.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/manager.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/manager.py new file mode 100644 index 0000000..3cd4fe5 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/manager.py @@ -0,0 +1,21 @@ +from flask_assets import ManageAssets +from flask_script import Manager +from flask_security.script import (CreateUserCommand, CreateRoleCommand, AddRoleCommand, + RemoveRoleCommand, DeactivateUserCommand, ActivateUserCommand) +from flask_migrate import Migrate, MigrateCommand + +from . import app, assets, db + +migrate = Migrate(app, db) + +manager = Manager(app) +manager.add_command('assets', ManageAssets(assets)) +manager.add_command('db', MigrateCommand) + +manager.add_command('create', CreateUserCommand, namespace='user') +manager.add_command('activate', ActivateUserCommand, namespace='user') +manager.add_command('deactivate', DeactivateUserCommand, namespace='user') +manager.add_command('add_role', AddRoleCommand, namespace='user') +manager.add_command('remove_role', RemoveRoleCommand, namespace='user') + +manager.add_command('create', CreateRoleCommand, namespace='role') diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/__init__.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/__init__.py new file mode 100644 index 0000000..33d28a4 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/__init__.py @@ -0,0 +1,10 @@ +import uuid + +from sqlalchemy.ext.declarative import AbstractConcreteBase + + +class Base(AbstractConcreteBase): + def __init__(self, id=None, *args, **kwargs): + if id is None: + id = str(uuid.uuid4()) + super(Base, self).__init__(id=id, *args, **kwargs) diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/role.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/role.py new file mode 100644 index 0000000..844b7b0 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/role.py @@ -0,0 +1,11 @@ +from flask_security import RoleMixin +from sqlalchemy.dialects.postgresql import UUID + +from . import Base +from .. import db + + +class Role(Base, db.Model, RoleMixin): + id = db.Column(UUID(), primary_key=True) + name = db.Column(db.String(80), unique=True) + description = db.Column(db.String(255)) diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/user.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/user.py new file mode 100644 index 0000000..332d69a --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/user.py @@ -0,0 +1,16 @@ +from flask_security import UserMixin +from sqlalchemy.dialects.postgresql import UUID + +from . import Base +from .. import db +from .user_role import user_role_map + + +class User(Base, db.Model, UserMixin): + id = db.Column(UUID(), primary_key=True) + email = db.Column(db.String(255), unique=True) + password = db.Column(db.String(255)) + active = db.Column(db.Boolean()) + confirmed_at = db.Column(db.DateTime()) + roles = db.relationship('Role', secondary=user_role_map, + backref=db.backref('users', lazy='dynamic')) diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/user_role.py b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/user_role.py new file mode 100644 index 0000000..976c39d --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/models/user_role.py @@ -0,0 +1,9 @@ +from sqlalchemy.dialects.postgresql import UUID + +from .. import db + +user_role_map = db.Table( + 'user_role', + db.Column('user_id', UUID(), db.ForeignKey('user.id')), + db.Column('role_id', UUID(), db.ForeignKey('role.id')), +) diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/static/js/app.js b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/static/js/app.js new file mode 100644 index 0000000..e69de29 diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/static/scss/app.scss b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/static/scss/app.scss new file mode 100644 index 0000000..6d2e340 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/static/scss/app.scss @@ -0,0 +1,6 @@ +@charset "UTF-8"; + +@import "https://fonts.googleapis.com/css?family=Lato"; +$family-sans-serif: "Lato", "Helvetica Neue", "Helvetica", "Arial", sans-serif; + +@import "bulma"; diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/_macros.jinja b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/_macros.jinja new file mode 100644 index 0000000..38920ad --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/_macros.jinja @@ -0,0 +1,30 @@ +{% macro render_link_for(view_name, inner_text, class="") %} + {% set url = url_for(view_name) %} + {% if request.endpoint == view_name %} + {% set class = class + " is-active" %} + {% endif %} + {{ inner_text }} +{% endmacro %} + +{% macro render_field_with_errors(field, class="input") %} +

+ {{ field.label(class="label") }} + + {% if field.errors %} + {% set class = class + " is-danger" %} + {% endif %} + {{ field(class=class, **kwargs)|safe }} + + {% if field.errors %} + {% for error in field.errors %} + {{ error }} + {% endfor %} + {% endif %} +

+{% endmacro %} + +{% macro render_field(field) %} +

+ {{ field(**kwargs)|safe }} +

+{% endmacro %} diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/_messages.jinja b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/_messages.jinja new file mode 100644 index 0000000..90de4f3 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/_messages.jinja @@ -0,0 +1,9 @@ +{%- with messages = get_flashed_messages(with_categories=true) -%} + {% if messages %} + {% for category, message in messages %} +
+

{{ message }}

+
+ {% endfor %} + {% endif %} +{%- endwith %} diff --git a/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/layout.jinja b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/layout.jinja new file mode 100644 index 0000000..bbc9ee1 --- /dev/null +++ b/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/templates/layout.jinja @@ -0,0 +1,47 @@ +{% from "_macros.jinja" import render_link_for %} + + + + + + + {% assets "css_all" %} + + {% endassets %} + + + +
+
+ {% block content %}{% endblock %} +
+
+