diff --git a/app/__init__.py b/app/__init__.py index 89b15211..f77d7e22 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,5 +1,13 @@ from config import config -from flask import Flask, render_template +from flask import Flask +from flask_login import LoginManager +from flask_sqlalchemy import SQLAlchemy + + +db = SQLAlchemy() + +login_manager = LoginManager() +login_manager.login_view = 'auth.login' def create_app(config_name): @@ -7,11 +15,13 @@ def create_app(config_name): app.config.from_object(config[config_name]) config[config_name].init_app(app) - @app.route('/') - def index(): - return render_template('base.html.j2') + db.init_app(app) + login_manager.init_app(app) from .auth import auth as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') + from .main import main as main_blueprint + app.register_blueprint(main_blueprint) + return app diff --git a/app/auth/forms.py b/app/auth/forms.py index e69de29b..d50cf956 100644 --- a/app/auth/forms.py +++ b/app/auth/forms.py @@ -0,0 +1,11 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, PasswordField, BooleanField, SubmitField +from wtforms.validators import DataRequired, Length, Email + + +class LoginForm(FlaskForm): + email = StringField('Email', validators=[DataRequired(), Length(1, 64), + Email()]) + password = PasswordField('Password', validators=[DataRequired()]) + remember_me = BooleanField('Keep me logged in') + submit = SubmitField('Log In') diff --git a/app/auth/views.py b/app/auth/views.py index fdec9b09..5eef5868 100644 --- a/app/auth/views.py +++ b/app/auth/views.py @@ -1,10 +1,31 @@ -from flask import render_template +from flask import flash, redirect, render_template, request, url_for +from flask_login import login_required, login_user, logout_user from . import auth +from .forms import LoginForm +from ..models import User @auth.route('/login', methods=['GET', 'POST']) def login(): - return render_template('auth/login.html.j2', title='Log in') + form = LoginForm() + if form.validate_on_submit(): + user = User.query.filter_by(email=form.email.data).first() + if user is not None and user.verify_password(form.password.data): + login_user(user, form.remember_me.data) + next = request.args.get('next') + if next is None or not next.startswith('/'): + next = url_for('main.index') + return redirect(next) + flash('Invalid username or password.') + return render_template('auth/login.html.j2', form=form, title='Log in') + + +@auth.route('/logout') +@login_required +def logout(): + logout_user() + flash('You have been logged out.') + return redirect(url_for('main.index')) @auth.route('/register', methods=['GET', 'POST']) diff --git a/app/main/__init__.py b/app/main/__init__.py index e69de29b..26860e81 100644 --- a/app/main/__init__.py +++ b/app/main/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +main = Blueprint('main', __name__) + +from . import views diff --git a/app/main/views.py b/app/main/views.py index e69de29b..db3f8030 100644 --- a/app/main/views.py +++ b/app/main/views.py @@ -0,0 +1,7 @@ +from flask import render_template +from . import main + + +@main.route('/') +def index(): + return render_template('main/index.html.j2') diff --git a/app/models.py b/app/models.py new file mode 100644 index 00000000..d6bccb02 --- /dev/null +++ b/app/models.py @@ -0,0 +1,43 @@ +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from . import db +from . import login_manager + + +class Role(db.Model): + __tablename__ = 'roles' + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(64), unique=True) + + def __repr__(self): + return '' % self.name + + +class User(UserMixin, db.Model): + __tablename__ = 'users' + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(64), unique=True, index=True) + username = db.Column(db.String(64), unique=True, index=True) + password_hash = db.Column(db.String(128)) + role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) + + def __repr__(self): + return '' % self.username + + password_hash = db.Column(db.String(128)) + + @property + def password(self): + raise AttributeError('password is not a readable attribute') + + @password.setter + def password(self, password): + self.password_hash = generate_password_hash(password) + + def verify_password(self, password): + return check_password_hash(self.password_hash, password) + + +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) diff --git a/app/templates/auth/login.html.j2 b/app/templates/auth/login.html.j2 index fc776c16..a5019b47 100644 --- a/app/templates/auth/login.html.j2 +++ b/app/templates/auth/login.html.j2 @@ -16,6 +16,7 @@ +
diff --git a/app/templates/base.html.j2 b/app/templates/base.html.j2 index 5f3995d9..ad9fea59 100644 --- a/app/templates/base.html.j2 +++ b/app/templates/base.html.j2 @@ -14,7 +14,35 @@ - {% include 'header.html.j2' %} +
+ + + + +
    +
  • Opaque
  • + {% if current_user.is_authenticated %} +
  • Log out
  • + {% else %} +
  • Log in
  • + {% endif %} +
+
@@ -25,7 +53,45 @@
- {% include 'footer.html.j2' %} + diff --git a/app/templates/footer.html.j2 b/app/templates/footer.html.j2 deleted file mode 100644 index e71c83fb..00000000 --- a/app/templates/footer.html.j2 +++ /dev/null @@ -1,39 +0,0 @@ - diff --git a/app/templates/header.html.j2 b/app/templates/header.html.j2 deleted file mode 100644 index 77e15064..00000000 --- a/app/templates/header.html.j2 +++ /dev/null @@ -1,25 +0,0 @@ -
- - - - - -
diff --git a/app/templates/index.html.j2 b/app/templates/main/index.html.j2 similarity index 82% rename from app/templates/index.html.j2 rename to app/templates/main/index.html.j2 index 3648311a..d111da6c 100644 --- a/app/templates/index.html.j2 +++ b/app/templates/main/index.html.j2 @@ -2,6 +2,19 @@ {% block page_content %}
+
+
+ + Hello, + {% if current_user.is_authenticated %} + {{ current_user.username }} + {% else %} + Stranger + {% endif %}! + +
+
+

Services

diff --git a/config.py b/config.py index 1a5d9d8b..0d9e342a 100644 --- a/config.py +++ b/config.py @@ -1,8 +1,12 @@ import os +basedir = os.path.abspath(os.path.dirname(__file__)) + + class Config: SECRET_KEY = os.environ.get('SECRET_KEY') or 'hard to guess string' + SQLALCHEMY_TRACK_MODIFICATIONS = False @staticmethod def init_app(app): @@ -11,6 +15,7 @@ class Config: class DevelopmentConfig(Config): DEBUG = True + SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(basedir, 'data_dev.sqlite') # class TestingConfig(Config): diff --git a/data_dev.sqlite b/data_dev.sqlite new file mode 100644 index 00000000..531008fb Binary files /dev/null and b/data_dev.sqlite differ diff --git a/migrations/README b/migrations/README new file mode 100644 index 00000000..98e4f9c4 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 00000000..f8ed4801 --- /dev/null +++ b/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/migrations/env.py b/migrations/env.py new file mode 100644 index 00000000..79b8174b --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,96 @@ +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').replace('%', '%%')) +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() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 00000000..2c015630 --- /dev/null +++ b/migrations/script.py.mako @@ -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"} diff --git a/migrations/versions/0d4e0dde8ae4_initial_migration.py b/migrations/versions/0d4e0dde8ae4_initial_migration.py new file mode 100644 index 00000000..e3658e38 --- /dev/null +++ b/migrations/versions/0d4e0dde8ae4_initial_migration.py @@ -0,0 +1,47 @@ +"""initial migration + +Revision ID: 0d4e0dde8ae4 +Revises: +Create Date: 2019-07-05 14:43:36.246455 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0d4e0dde8ae4' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('roles', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('name') + ) + op.create_table('users', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=64), nullable=True), + sa.Column('username', sa.String(length=64), nullable=True), + sa.Column('role_id', sa.Integer(), nullable=True), + sa.Column('password_hash', sa.String(length=128), nullable=True), + sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_users_username'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_table('roles') + # ### end Alembic commands ### diff --git a/opaque.py b/opaque.py index 8a87447a..75a1e562 100644 --- a/opaque.py +++ b/opaque.py @@ -1,5 +1,13 @@ -from app import create_app +from app import create_app, db +from app.models import User, Role +from flask_migrate import Migrate import os app = create_app(os.getenv('FLASK_CONFIG') or 'default') +migrate = Migrate(app, db) + + +@app.shell_context_processor +def make_shell_context(): + return dict(db=db, User=User, Role=Role) diff --git a/requirements.txt b/requirements.txt index 5cf71fa3..5e13218c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ Flask==1.0.3 Flask-Login==0.4.1 +Flask-Migrate==2.5.2 Flask-SQLAlchemy==2.4.0 +Flask-WTF==0.14.2 python-dotenv==0.10.3