Initial commit

This commit is contained in:
Piotr Dobrowolski 2018-03-31 18:26:40 +02:00
commit 94f0009144
54 changed files with 9069 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
*.py[co]
*.swp
app.db
db.sqlite3
.coverage
.coverage_report
.bash_history
.ropeproject
ssl
flaskbase/settings/production.py

29
Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM ubuntu
RUN useradd -d /app app
RUN apt-get update && \
apt-get -y install python \
python-dev \
python-pip \
libpq-dev \
uwsgi \
uwsgi-plugin-python \
git
# We use that to cache last requirements version
COPY requirements.txt /app/requirements.txt
RUN pip install -r /app/requirements.txt
RUN apt-get -y install python-psycopg2
WORKDIR /app
EXPOSE 8000
USER app
ENV PYTHONUNBUFFERED 1
CMD ./manage.py db upgrade && \
uwsgi --http-socket :8000 --processes 4 --master --plugin python \
--pythonpath /app --module flaskbase.wsgi:application \
--touch-reload '/app/flaskbase/wsgi.py'

29
README.md Normal file
View File

@ -0,0 +1,29 @@
A simple go-to Flask template project incorporating many cool module, eg:
* Flask-Security for user and permissions management
* Flask-Social for social login
* Flask-WTF for form management
* Flask-SQLAlchemy for database access
* Flask-Migrate for migrations
* Flask-Admin for admin panel
* Flask-Script for management commands
* Admin is only accessible for `is_superuser`'s
* Social authorization automatically creates user model and allows him to make
sure email fetched from social (Facebook only right now) is correct.
After all, when you look at it, it's just a Django-for-Flask... :)
Usage
-----
* Clone repository
* `mv flaskbase yourproject`
* `find . -type f -print0 | xargs -0 sed -i 's/flaskbase/yourproject/g'`
* ...
* PROFIT!
TODO
----
* Migrate to Flask-Social-Blueprint. Much cleaner.

0
auth/__init__.py Normal file
View File

90
auth/admin.py Normal file
View File

@ -0,0 +1,90 @@
from flaskbase.extensions import admin, db
from flaskbase.admin import ModelView
from auth.models import User, Role, Connection
from jinja2 import Markup
from flask import flash, redirect
from flask_admin.babel import gettext
from flask_admin.base import expose
from flask_admin.helpers import (get_redirect_target, flash_errors)
from flask_security import login_user
class UserModelView(ModelView):
column_searchable_list = (
'email', 'current_login_ip', 'last_login_ip',
)
column_list = (
'email', 'active', 'is_superuser', 'last_login_at', 'last_login_ip',
'display_name'
)
column_filters = column_list
column_sortable_list = column_list
column_labels = {
'is_superuser': 'SU',
}
column_descriptions = {
'is_superuser': 'Superuser',
}
inline_models = (Connection,)
list_template = 'admin/user_list.html'
can_impersonate = True
@expose('/impersonate/', methods=('POST',))
def impersonate_view(self):
"""
Impersonate user view. Only POST method is allowed.
"""
return_url = get_redirect_target() or self.get_url('.index_view')
if not self.can_impersonate:
return redirect(return_url)
form = self.delete_form()
if self.validate_form(form):
# id is Required()
id = form.id.data
model = self.get_one(id)
if model is None:
return redirect(return_url)
if login_user(model):
flash(gettext('You are now %s' % (model,)))
return redirect('/')
else:
flash_errors(form, message='Failed to impersonate. %(error)s')
return redirect(return_url)
class ConnectionModelView(ModelView):
column_list = (
'user',
'provider_id',
'full_name',
'email',
)
column_formatters = {
'full_name':
lambda v, c, m, n: Markup(
'<a href="%s" target="_blank"><img src="%s" /> %s</a>') % (
m.profile_url, m.image_url, m.full_name)
}
column_filters = column_list
column_sortable_list = column_list
admin.add_view(UserModelView(User, db.session))
admin.add_view(ModelView(Role, db.session))
admin.add_view(ConnectionModelView(Connection, db.session))

21
auth/commands.py Normal file
View File

@ -0,0 +1,21 @@
from flask_script import Manager
from auth.models import User
from flaskbase.extensions import db
import datetime
manager = Manager()
@manager.option('-e', '--email', help='User email', default='root')
@manager.option('-p', '--password', help='Password', default='t00r')
def create_superuser(email, password):
try:
u = User(email=email, password=password, active=True,
is_superuser=True, confirmed_at=datetime.datetime.utcnow())
db.session.add(u)
db.session.commit()
print('User %s successfuly created.' % (email,))
except Exception, e:
print('User creation failed: %s' % (e,))

12
auth/factories.py Normal file
View File

@ -0,0 +1,12 @@
from flaskbase.factories import BaseFactory
from factory import Sequence
from auth.models import User
class UserFactory(BaseFactory):
email = Sequence(lambda n: 'user{0}@example.com'.format(n))
password = Sequence(lambda n: 'UserPassword{0}'.format(n))
active = True
class Meta:
model = User

6
auth/forms.py Normal file
View File

@ -0,0 +1,6 @@
from flask_wtf import Form
from flask_security.forms import UniqueEmailFormMixin
class SocialLoginConfirmForm(Form, UniqueEmailFormMixin):
pass

120
auth/models.py Normal file
View File

@ -0,0 +1,120 @@
from flask import g, session, url_for
from flask_security import RoleMixin, UserMixin
from flask_login import user_logged_in
from flaskbase.extensions import db, social
from importlib import import_module
# Define models
roles_users = db.Table(
'roles_users',
db.Column('user_id', db.Integer(), db.ForeignKey('users.id')),
db.Column('role_id', db.Integer(), db.ForeignKey('roles.id'))
)
class Role(db.Model, RoleMixin):
__tablename__ = 'roles'
id = db.Column(db.Integer(), primary_key=True)
name = db.Column(db.String(80), unique=True)
description = db.Column(db.String(255))
def __unicode__(self):
return '{0.description} ({0.name})'.format(self)
class User(db.Model, UserMixin):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True)
password = db.Column(db.String(255))
active = db.Column(db.Boolean())
is_superuser = db.Column(db.Boolean(), default=False)
last_login_ip = db.Column(db.String(128))
last_login_at = db.Column(db.DateTime())
current_login_ip = db.Column(db.String(128))
current_login_at = db.Column(db.DateTime())
login_count = db.Column(db.Integer)
invited_by_id = db.Column(db.Integer, db.ForeignKey('users.id',
ondelete='SET NULL'))
invited_by = db.relationship('User', remote_side=[id],
backref='invited_users')
confirmed_at = db.Column(db.DateTime())
roles = db.relationship('Role', secondary=roles_users,
backref=db.backref('users', lazy='dynamic'))
display_name = db.Column(db.String(128), info={
'label': 'Display name',
'description': 'Your email will be used instead if empty.',
})
def __repr__(self):
return '<User {0.email}>'.format(self)
def __unicode__(self):
return self.display_name or self.email
@property
def image_url(self):
if self.connections:
return self.connections[0].image_url
return None
def first_login(self):
"""Checks if user has just logged in (used in base template)"""
g.first_login = session.pop('first_login', False) or \
g.get('first_login', False)
return g.first_login
@property
def profile_url(self):
return url_for('profile.public', id=self.id, _external=True)
@property
def is_ghost(self):
return not self.confirmed_at
@user_logged_in.connect
def track_first_login(app, user):
"""Tracks first user login"""
if not user.login_count:
session['first_login'] = True
class Connection(db.Model):
__tablename__ = 'connections'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
user = db.relationship('User', backref='connections')
provider_id = db.Column(db.String(255))
provider_user_id = db.Column(db.String(255))
access_token = db.Column(db.String(255))
secret = db.Column(db.String(255))
email = db.Column(db.String(255))
full_name = db.Column(db.String(255))
display_name = db.Column(db.String(255))
profile_url = db.Column(db.String(512))
image_url = db.Column(db.String(512))
rank = db.Column(db.Integer)
def __repr__(self):
return '<Connection {0.provider_id}:{0.provider_user_id} {0.user}>' \
.format(self)
def get_api(self):
provider = social.providers.get(self.provider_id)
if not provider:
return None
module = import_module(provider.module)
return module.get_api(connection=self,
consumer_key=provider.consumer_key,
consumer_secret=provider.consumer_secret)

80
auth/views.py Normal file
View File

@ -0,0 +1,80 @@
from flask import Blueprint, render_template, \
redirect, url_for, session, abort
from flask import current_app as app
from flask_security import login_user
from flask_security.confirmable import confirm_user
from flask_social import connection_created, login_completed, login_failed
from flask_social.utils import get_connection_values_from_oauth_response, \
get_provider_or_404
from flask_social.views import connect_handler
from werkzeug.local import LocalProxy
from flaskbase.extensions import db
from auth.forms import SocialLoginConfirmForm
blueprint = Blueprint('auth', __name__)
_security = LocalProxy(lambda: app.extensions['security'])
@connection_created.connect
@login_completed.connect
def on_connection_created(app, connection=None, user=None, provider=None):
if not connection:
connection = provider.get_connection()
if not connection.user and user:
connection.user = user
db.session.commit()
@login_failed.connect
def on_login_failed(app, provider, oauth_response):
session['failed_login_connection'] = \
get_connection_values_from_oauth_response(provider, oauth_response)
abort(redirect(url_for('auth.confirm_social')))
@blueprint.route('/confirm_social', methods=['GET', 'POST'])
def confirm_social():
connection_values = session.get('failed_login_connection', None)
if not connection_values:
return redirect('/')
form = SocialLoginConfirmForm(
email=connection_values.get('email', ''),
)
if form.validate_on_submit():
# Prevent Flask-Security form sending confirmation email
_security.confirmable = False
kwargs = {'email': form.email.data}
if connection_values.get('full_name'):
kwargs['display_name'] = connection_values['full_name']
# Create and login user
user = _security.datastore.create_user(**kwargs)
confirm_user(user)
_security.datastore.commit()
login_user(user)
# TODO: possibly move it to user_logged_in signal handler?
# Process pending social connection
connection_values = session.pop('failed_login_connection', None)
connection_values['user_id'] = user.id
connect_handler(
connection_values,
get_provider_or_404(connection_values['provider_id']))
_security.datastore.commit()
return redirect('/')
return render_template('security/confirm_social.html', form=form)

29
docker-compose.yml Normal file
View File

@ -0,0 +1,29 @@
postgresdata:
image: postgres
volumes:
- /var/lib/postgres
command: /bin/true
postgres:
image: postgres
volumes_from:
- postgresdata
web:
build: .
links:
- postgres
volumes:
- ".:/app"
environment:
VIRTUAL_HOST: flaskbase.local
ENV: development
proxy:
image: jwilder/nginx-proxy
volumes:
- /var/run/docker.sock:/tmp/docker.sock
ports:
- "80:80"
environment:
DEFAULT_HOST: flaskbase.local

76
flaskbase/__init__.py Normal file
View File

@ -0,0 +1,76 @@
import flask
import logging
import sys
import os
from flaskbase.extensions import db
from flaskbase.middleware import MethodRewriteMiddleware
_initializers = set()
def import_submodules(app, submodule):
ret = list()
for mod_name in app.config['ENABLED_MODULES']:
try:
# FIXME support for mod_name directing to submodule
mod = __import__('%s.%s' % (mod_name, submodule))
ret.append((mod_name, getattr(mod, submodule)))
except ImportError:
# Check if error occured right in that try clause
if sys.exc_info()[2].tb_next:
logging.warning('Import failed for %s.%s', mod_name, submodule,
exc_info=True)
return ret
def create_app(settings_object=None, settings={}):
app = flask.Flask(
__name__,
template_folder='../templates',
static_folder='../static'
)
if 'SETTINGS_MODULE' in os.environ:
app.config.from_envvar('SETTINGS_MODULE')
else:
if not settings_object:
settings_object = 'flaskbase.settings.{}'.format(
os.environ.get('ENV', 'production'))
app.config.from_object(settings_object)
app.config.update(settings)
# FIXME https://github.com/mattupstate/flask-social/issues/34
app.wsgi_app = MethodRewriteMiddleware(app.wsgi_app)
for m_name, m in import_submodules(app, 'extensions'):
m.init_app(app)
import_submodules(app, 'models')
for m_name, m in import_submodules(app, 'views'):
app.register_blueprint(
m.blueprint,
url_prefix=app.config['URLS'].get(m_name, '/' + m.blueprint.name),
)
import_submodules(app, 'admin')
for fun in _initializers:
fun(app)
logging.info('Starting...')
return app
def initializer(f):
_initializers.add(f)
return f
def init_db(app):
db.create_all()

26
flaskbase/admin.py Normal file
View File

@ -0,0 +1,26 @@
from flask_admin.contrib.sqla import ModelView as _ModelView
from flask_admin import BaseView as _BaseView
from flask_security import current_user
from flask_principal import RoleNeed, Permission
# FIXME admin homepage is always accessible
class AdminProtect(object):
roles = []
def is_accessible(self):
return current_user.is_authenticated() and \
(current_user.is_superuser or
(self.roles and
Permission(*[RoleNeed(r) for r in self.roles]).can()))
class ModelView(AdminProtect, _ModelView):
def __init__(self, model, *args, **kwargs):
kwargs.setdefault('endpoint', model.__name__.lower() + '_model')
return super(ModelView, self).__init__(model, *args, **kwargs)
named_filter_urls = True
class BaseView(AdminProtect, _BaseView):
pass

52
flaskbase/extensions.py Normal file
View File

@ -0,0 +1,52 @@
import logging
import logging.config
logging.basicConfig(level=logging.INFO)
from flask import url_for, render_template
from flask_sqlalchemy import SQLAlchemy
from flask_security import Security, SQLAlchemyUserDatastore
from flask_social import Social
from flask_social.datastore import SQLAlchemyConnectionDatastore
from flask_mail import Mail
from flask_migrate import Migrate
from flask_admin import Admin
from flask_wtf.csrf import CsrfProtect
from flask.ext.markdown import Markdown
from flask_pagedown import PageDown
db = SQLAlchemy()
security = Security()
social = Social()
mail = Mail()
migrate = Migrate()
admin = Admin(template_mode='bootstrap3')
def init_app(app):
db.init_app(app)
mail.init_app(app)
admin.init_app(app)
migrate.init_app(app, db)
@app.context_processor
def utility_processor():
return {
'static': lambda fn: url_for('static', filename=fn)
}
@app.errorhandler(404)
def page_not_found(e):
return render_template('404.html'), 404
if app.config.get('LOGGING'):
logging.config.dictConfig(app.config['LOGGING'])
from auth.models import User, Role, Connection
security.init_app(app, SQLAlchemyUserDatastore(db, User, Role))
social._state = social.init_app(
app, SQLAlchemyConnectionDatastore(db, Connection))
CsrfProtect(app)
Markdown(app)
PageDown(app)

8
flaskbase/factories.py Normal file
View File

@ -0,0 +1,8 @@
from factory.alchemy import SQLAlchemyModelFactory
from flaskbase.extensions import db
class BaseFactory(SQLAlchemyModelFactory):
class Meta:
abstract = True
sqlalchemy_session = db.session

13
flaskbase/forms.py Normal file
View File

@ -0,0 +1,13 @@
from flask_wtf import Form
from wtforms_alchemy import model_form_factory
# The variable db here is a SQLAlchemy object instance from
# Flask-SQLAlchemy package
from flaskbase.extensions import db
BaseModelForm = model_form_factory(Form)
class ModelForm(BaseModelForm):
@classmethod
def get_session(self):
return db.session

16
flaskbase/middleware.py Normal file
View File

@ -0,0 +1,16 @@
from werkzeug import url_decode
class MethodRewriteMiddleware(object):
def __init__(self, app):
self.app = app
def __call__(self, environ, start_response):
if 'METHOD_OVERRIDE' in environ.get('QUERY_STRING', ''):
args = url_decode(environ['QUERY_STRING'])
method = args.get('__METHOD_OVERRIDE__')
if method:
method = method.encode('ascii', 'replace')
environ['REQUEST_METHOD'] = method
return self.app(environ, start_response)

View File

View File

@ -0,0 +1,76 @@
import os.path
PROJECT_NAME = 'flaskbase'.capitalize()
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
DEBUG = True
SECRET_KEY = 'nothing'
SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(BASE_DIR, 'db.sqlite3')
SQLALCHEMY_TRACK_MODIFICATIONS = True
ENABLED_MODULES = [
'flaskbase',
'auth',
'flatpages',
]
URLS = {
'flaskbase': '',
'flatpages': '/p',
}
SECURITY_REGISTERABLE = True
SECURITY_RECOVERABLE = True
SECURITY_CHANGEABLE = True
SECURITY_CONFIRMABLE = True
SECURITY_TRACKABLE = True
SECURITY_URL_PREFIX = '/auth'
SECURITY_PASSWORD_HASH = 'bcrypt'
SECURITY_PASSWORD_SALT = SECRET_KEY
SECURITY_EMAIL_SUBJECT_REGISTER = 'Welcome to ' + PROJECT_NAME
SECURITY_INVITE_WITHIN = '60 days'
MAIL_SUPPRESS_SEND = DEBUG
# Logging setup
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
},
},
'handlers': {
'default': {
'level': 'INFO',
'class': 'logging.handlers.TimedRotatingFileHandler',
'filename': os.path.join(BASE_DIR, 'logs', 'app.log'),
'formatter': 'standard',
'when': 'midnight',
},
'console': {
'level': 'INFO',
'class': 'logging.StreamHandler',
'formatter': 'standard'
},
},
'loggers': {
'': {
'handlers': ['default', 'console'],
'level': 'INFO',
'propagate': True
},
'django.request': {
'handlers': ['default'],
'level': 'WARN',
'propagate': False
},
}
}

View File

@ -0,0 +1,21 @@
from flaskbase.settings.base import *
#SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://postgres:postgres@postgres'
SQLALCHEMY_DATABASE_URI = 'sqlite:////tmp/flaskbase.db'
SOCIAL_FACEBOOK = {
'consumer_key': 'APP-ID',
'consumer_secret': 'APP-SECRET',
'request_token_params': {
'scope': 'email,user_friends',
}
}
from flask_mail import email_dispatched
def log_message(message, app):
app.logger.debug('subject:%s', message.subject)
app.logger.debug('body:%s', message.body)
email_dispatched.connect(log_message)

View File

@ -0,0 +1,4 @@
from flaskbase.settings.base import *
TESTING = True
SQLALCHEMY_DATABASE_URI = 'postgresql+psycopg2://postgres:postgres@postgres/testing'

27
flaskbase/tests.py Normal file
View File

@ -0,0 +1,27 @@
import unittest
import flaskbase
import contextlib
from flaskbase.extensions import db
class TestCase(unittest.TestCase):
"""
Base testcase class
"""
def setUp(self):
self.flask_app = flaskbase.create_app(settings={
'SQLALCHEMY_DATABASE_URI': 'sqlite://',
})
self.app = self.flask_app.test_client()
flaskbase.init_db(self.flask_app)
def tearDown(self):
db.session.remove()
with contextlib.closing(db.engine.connect()) as con:
trans = con.begin()
for table in reversed(db.Model.metadata.sorted_tables):
con.execute(table.delete())
trans.commit()

9
flaskbase/utils.py Normal file
View File

@ -0,0 +1,9 @@
from sqlalchemy.orm import exc
from werkzeug.exceptions import abort
def get_object_or_404(model, *criterion):
try:
return model.query.filter(*criterion).one()
except exc.NoResultFound, exc.MultipleResultsFound:
abort(404)

8
flaskbase/views.py Normal file
View File

@ -0,0 +1,8 @@
from flask import Blueprint, render_template
blueprint = Blueprint('core', __name__)
@blueprint.route('/')
def index():
return render_template('index.html')

3
flaskbase/wsgi.py Normal file
View File

@ -0,0 +1,3 @@
from flaskbase import create_app
application = create_app()

0
flatpages/__init__.py Normal file
View File

22
flatpages/admin.py Normal file
View File

@ -0,0 +1,22 @@
from flaskbase.admin import ModelView
from flaskbase.extensions import admin, db
from flatpages.models import Page
from flask_pagedown.fields import PageDownField
class PageModelView(ModelView):
roles = ['editor']
column_list = ('slug', 'title', 'updated')
form_columns = ('title', 'slug', 'content')
form_extra_fields = {
'content': PageDownField('Content'),
}
form_widget_args = {
'content': {
'rows': 10,
}
}
admin.add_view(PageModelView(Page, db.session))

22
flatpages/models.py Normal file
View File

@ -0,0 +1,22 @@
from flaskbase.extensions import db
from datetime import datetime
from sqlalchemy.orm import foreign, remote
class Page(db.Model):
__tablename__ = 'pages'
id = db.Column(db.Integer, primary_key=True)
slug = db.Column(db.String(64), unique=True)
title = db.Column(db.String(256))
content = db.Column(db.Text)
created = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated = db.Column(db.DateTime, default=datetime.utcnow, nullable=False,
onupdate=datetime.utcnow)
subpages = db.relationship(
'Page', primaryjoin=remote(foreign(slug)).like(slug.concat('/%')),
viewonly=True)
def __repr__(self):
return '<Page {0.slug} "{0.title}">'.format(self)

13
flatpages/views.py Normal file
View File

@ -0,0 +1,13 @@
from flask import Blueprint, render_template
from flaskbase.utils import get_object_or_404
from flatpages.models import Page
blueprint = Blueprint('flatpages', __name__)
@blueprint.route('/<path:slug>')
def show(slug):
return render_template(
'flatpages/show.html',
page=get_object_or_404(Page, Page.slug == slug),
)

2
logs/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*
!.gitignore

57
manage.py Executable file
View File

@ -0,0 +1,57 @@
#!/usr/bin/env python
from flask_script import Manager
from flask_migrate import MigrateCommand
from flaskbase import create_app, import_submodules
from flaskbase.extensions import db
app = create_app()
manager = Manager(app)
manager.add_command('db', MigrateCommand)
for m_name, m in import_submodules(app, 'commands'):
manager.add_command(m_name.replace('mod_', ''), m.manager)
@manager.command
def syncdb():
db.create_all()
try:
from flask.ext.zen import Test, ZenTest
from flask_script import Option
from coverage import coverage
class CoverageTest(Test):
def get_options(self):
return super(CoverageTest, self).get_options() + [
Option('-H', '--html', dest='html', default=None,
help='Save HTML report to directory'),
]
def run(self, html=None, *args, **kwargs):
cov = coverage(branch=True, source=['.'],
omit=['*/tests.py', 'tests.py', 'manage.py',
'flaskbase/settings/*', '*/admin.py'])
cov.start()
super(CoverageTest, self).run(*args, **kwargs)
cov.stop()
cov.save()
print
cov.report()
if html:
cov.html_report(directory=html)
print '\nHTML coverage report saved to', html
manager.add_command('test', Test())
manager.add_command('coverage', CoverageTest())
manager.add_command('zen', ZenTest())
except ImportError:
pass
if __name__ == '__main__':
manager.run()

1
migrations/README Executable file
View File

@ -0,0 +1 @@
Generic single-database configuration.

45
migrations/alembic.ini Normal file
View File

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

73
migrations/env.py Executable file
View File

@ -0,0 +1,73 @@
from __future__ import with_statement
from alembic import context
from sqlalchemy import engine_from_config, pool
from logging.config import fileConfig
# 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)
# 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.
"""
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,
**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()

22
migrations/script.py.mako Executable file
View File

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

View File

@ -0,0 +1,86 @@
"""Initial migration
Revision ID: 31d442356906
Revises: None
Create Date: 2018-03-31 18:18:51.582410
"""
# revision identifiers, used by Alembic.
revision = '31d442356906'
down_revision = None
from alembic import op
import sqlalchemy as sa
def upgrade():
### commands auto generated by Alembic - please adjust! ###
op.create_table('pages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('slug', sa.String(length=64), nullable=True),
sa.Column('title', sa.String(length=256), nullable=True),
sa.Column('content', sa.Text(), nullable=True),
sa.Column('created', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('slug')
)
op.create_table('roles',
sa.Column('id', sa.Integer(), 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('users',
sa.Column('id', sa.Integer(), 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('is_superuser', sa.Boolean(), nullable=True),
sa.Column('last_login_ip', sa.String(length=128), nullable=True),
sa.Column('last_login_at', sa.DateTime(), nullable=True),
sa.Column('current_login_ip', sa.String(length=128), nullable=True),
sa.Column('current_login_at', sa.DateTime(), nullable=True),
sa.Column('login_count', sa.Integer(), nullable=True),
sa.Column('invited_by_id', sa.Integer(), nullable=True),
sa.Column('confirmed_at', sa.DateTime(), nullable=True),
sa.Column('display_name', sa.String(length=128), nullable=True),
sa.ForeignKeyConstraint(['invited_by_id'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('connections',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('provider_id', sa.String(length=255), nullable=True),
sa.Column('provider_user_id', sa.String(length=255), nullable=True),
sa.Column('access_token', sa.String(length=255), nullable=True),
sa.Column('secret', sa.String(length=255), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('full_name', sa.String(length=255), nullable=True),
sa.Column('display_name', sa.String(length=255), nullable=True),
sa.Column('profile_url', sa.String(length=512), nullable=True),
sa.Column('image_url', sa.String(length=512), nullable=True),
sa.Column('rank', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('roles_users',
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('role_id', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], )
)
### end Alembic commands ###
def downgrade():
### commands auto generated by Alembic - please adjust! ###
op.drop_table('roles_users')
op.drop_table('connections')
op.drop_table('users')
op.drop_table('roles')
op.drop_table('pages')
### end Alembic commands ###

40
requirements.txt Normal file
View File

@ -0,0 +1,40 @@
Flask==0.10.1
Flask-Admin==1.2.0
Flask-Login==0.2.11
Flask-Mail==0.9.1
Flask-Markdown==0.3
Flask-Migrate==1.5.0
Flask-OAuth==0.12
Flask-PageDown==0.2.2
Flask-Principal==0.4.0
Flask-SQLAlchemy==2.0
Flask-Script==2.0.5
Flask-Security==1.7.4
Flask-Social==1.6.2
Flask-WTF==0.12
Jinja2==2.8
Mako==1.0.1
Markdown==2.6.11
MarkupSafe==0.23
SQLAlchemy==1.0.8
WTForms==2.0.2
Werkzeug==0.10.4
alembic==0.7.7
argparse==1.2.1
bcrypt==3.1.4
blinker==1.4
certifi==2018.1.18
cffi==1.11.5
chardet==3.0.4
facebook-sdk==2.0.0
httplib2==0.9.1
idna==2.6
itsdangerous==0.24
oauth2==1.5.211
passlib==1.6.5
psycopg2==2.7.4
pycparser==2.18
requests==2.18.4
six==1.11.0
urllib3==1.22
wsgiref==0.1.2

7500
static/css/bootstrap.css vendored Normal file

File diff suppressed because it is too large Load Diff

6
templates/404.html Normal file
View File

@ -0,0 +1,6 @@
{% extends "base.html" %}
{% set page_title = '404' %}
{% block title_actions %}
<small>Page could not be found</small>
{% endblock %}

View File

@ -0,0 +1,56 @@
{% macro render_field(field, prefix=None, suffix=None, layout=True, label=True) %}
{% if field.type == 'HiddenField' %}
{{ field(**kwargs) }}
{% else %}
{% if layout %}
<div class="form-group{% if field.errors %} has-error{% endif %}">
{% if field.type == 'BooleanField' %}
<div class="col-xs-3"></div>
{% elif label %}
{{ field.label(class_='col-xs-3 control-label') }}
{% endif %}
<div class="col-xs-9">
{% endif %}
{{ render_field_inner(field, prefix, suffix, label=label, **kwargs) }}
{% if layout %}
</div>
</div>
{% endif %}
{% endif %}
{% endmacro %}
{% macro render_field_inner(field, prefix=None, suffix=None, label=True) %}
{% if field.type == 'BooleanField' %}<div class="checkbox"><label for="{{ field.id }}">{% endif %}
{% if prefix or suffix %}<div class="input-group">{% endif %}
{% if prefix %}<span class="input-group-addon">{{ prefix }}</span>{% endif %}
{% if field.type == 'BooleanField' %}
{{ field(**kwargs) }} {% if label %}{{ field.label.text }}{% endif %}
{% else %}
{{ field(class_='form-control', **kwargs) }}
{% endif %}
{% if suffix %}<span class="input-group-addon">{{ suffix }}</span>{% endif %}
{% if prefix or suffix %}</div>{% endif %}
{% if field.description %}
<span class="help-block">{{ field.description }}</span>
{% endif %}
{% if field.errors %}
{% for error in field.errors %}
<span class="help-block">{{ error }}</span>
{% endfor %}
{% endif %}
{% if field.type == 'BooleanField' %}</label></div>{% endif %}
{% endmacro %}
{% macro render_submit(label='Submit', class_='btn btn-primary', layout=True) %}
{% if layout %}
<div class="form-group">
<div class="col-xs-9 col-xs-offset-3">
{% endif %}
<button type="submit" class="{{ class_ }}">{{ label }}</button>
{% if layout %}
</div>
</div>
{% endif %}
{% endmacro %}

View File

@ -0,0 +1,32 @@
{% macro social_register(provider_id, display_name) %}
<form action="{{ url_for('social.login', provider_id=provider_id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-primary btn-large">Register with {{ display_name }}</button>
</form>
{% endmacro %}
{% macro social_login(provider_id, display_name) %}
<form action="{{ url_for('social.login', provider_id=provider_id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-primary btn-large">Login with {{ display_name }}</button>
</form>
{% endmacro %}
{% macro show_provider_button(provider_id, display_name, conn, btn_type='primary') %}
{% if conn %}
{#
<form action="{{ url_for('social.remove_connection', provider_id=conn.provider_id, provider_user_id=conn.provider_user_id) }}?__METHOD_OVERRIDE__=DELETE" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<div class="btn-group" role="group" aria-label="...">
<button type="submit" class="btn btn-{{ btn_type }}">Disconnect {{ display_name }}</button>
</div>
</form>
#}
<button type="button" class="btn btn-default invite-friends">Invite {{ display_name }} friends&nbsp;&nbsp;<i class="glyphicon glyphicon-share-alt"></i></button>
{% else %}
<form action="{{ url_for('social.connect', provider_id=provider_id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<button type="submit" class="btn btn-{{ btn_type }}">Connect {{ display_name }}</button>
</form>
{% endif %}
{% endmacro %}

View File

@ -0,0 +1,32 @@
{% import 'admin/static.html' as admin_static with context %}
{% macro dropdown(actions, btn_class='dropdown-toggle') -%}
<a class="{{ btn_class }}" data-toggle="dropdown" href="javascript:void(0)"><b>{{ _gettext('With selected') }}</b><b class="caret"></b></a>
<ul class="dropdown-menu">
{% for p in actions %}
<li>
<a href="javascript:void(0)" onclick="return modelActions.execute('{{ p[0] }}');">{{ _gettext(p[1]) }}</a>
</li>
{% endfor %}
</ul>
{% endmacro %}
{% macro form(actions, url) %}
{% if actions %}
<form id="action_form" action="{{ url }}" method="POST" style="display: none">
{% if csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<input type="hidden" id="action" name="action" />
</form>
{% endif %}
{% endmacro %}
{% macro script(message, actions, actions_confirmation) %}
{% if actions %}
<script src="{{ admin_static.url(filename='admin/js/actions-1.0.0.js') }}"></script>
<script language="javascript">
var modelActions = new AdminModelActions({{ message|tojson|safe }}, {{ actions_confirmation|tojson|safe }});
</script>
{% endif %}
{% endmacro %}

27
templates/admin/base.html Normal file
View File

@ -0,0 +1,27 @@
{% extends 'base.html' %}
{% import 'admin/layout.html' as layout with context -%}
{% import 'admin/static.html' as admin_static with context %}
{% block head_css %}{{ super() }}
<link href="{{ admin_static.url(filename='admin/css/bootstrap3/admin.css') }}" rel="stylesheet">
{% endblock %}
{% block content %}
<div class="col-md-2">
<ul class="nav nav-pills nav-stacked">
{{ layout.menu() }}
{{ layout.menu_links() }}
</ul>
</div>
<div class="col-md-10">
{% set render_ctx = h.resolve_ctx() %}
{% block body %}{% endblock %}
</div>
{% endblock %}
{% block tail_js %}{{ super() }}
<script src="{{ admin_static.url(filename='vendor/moment-2.8.4.min.js') }}" type="text/javascript"></script>
<script src="{{ admin_static.url(filename='vendor/select2/select2.min.js') }}" type="text/javascript"></script>
{{ pagedown.include_pagedown() }}
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends 'admin/model/list.html' %}
{% block list_row_actions %}{{ super() }}
{% if admin_view.can_impersonate %}
<form class="icon" method="POST" action="{{ get_url('.impersonate_view') }}">
{{ delete_form.id(value=get_pk_value(row)) }}
{{ delete_form.url(value=return_url) }}
{% if delete_form.csrf_token %}
{{ delete_form.csrf_token }}
{% elif csrf_token %}
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
{% endif %}
<button onclick="return confirm('{{ _gettext('Are you sure you want to impersonate as this user?') }}');" title="Impersonate user">
<span class="glyphicon glyphicon-eye-open"></span>
</button>
</form>
{% endif %}
{% endblock %}

123
templates/base.html Normal file
View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
{% block head_meta %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
{% endblock %}
<title>{{ config['PROJECT_NAME'] }}{% if page_title %} - {{ page_title }}{% endif %}</title>
{% block head_css %}
<link rel="stylesheet" href="{{ static('css/bootstrap.css') }}" media="screen">
<link rel="stylesheet" href="{{ static('css/base.css') }}" media="screen">
{% endblock -%}
{%- block head %}{% endblock -%}
{%- block head_tail -%}{%- endblock -%}
</head>
<body>
<div class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a href="/" class="navbar-brand"><i class="glyphicon glyphicon-stats"></i>&nbsp;{{ config['PROJECT_NAME'] }}
{%- if config['DEBUG'] or config.get('BRAND_NOTE') %} <small>{{ config.get('BRAND_NOTE', 'development') }}</small>{% endif -%}
</a>
<button class="navbar-toggle" type="button" data-toggle="collapse" data-target="#navbar-main">
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
</div>
<div class="navbar-collapse collapse" id="navbar-main">
{% macro navbar_link(href, title=None) %}
<li{% if href == request.endpoint and request.view_args == kwargs %} class="active"{% endif %}>
<a href="{{ url_for(href, **kwargs) }}">{% if not title %}{{ caller() }}{% else %}{{ title }}{% endif %}</a>
</li>
{% endmacro %}
{% block main_menu %}
{# Here add your main menu items, eg: #}
{# navbar_link('friends.index', 'Friends') #}
{% endblock %}
<ul class="nav navbar-nav navbar-right">
{{ navbar_link('flatpages.show', 'FAQ', slug='faq') }}
{% if current_user.is_authenticated() %}
{% if current_user.is_superuser or current_user.roles %}
{{ navbar_link('admin.index', 'Admin') }}
{% endif %}
<p class="navbar-text">{{ current_user }}</p>
{{ navbar_link('security.logout', 'Logout') }}
{% else %}
{{ navbar_link('security.register', 'Sign up') }}
{{ navbar_link('security.login', 'Login') }}
{% endif %}
</ul>
</div>
</div>
</div>
<div class="container">
{% block messages %}
{% with messages = get_flashed_messages(with_categories=True) +
config.get('GLOBAL_FLASHED_MESSAGES', []) %}
{% if messages %}
{% for category, message in messages %}
{% set category = 'info' if category == 'message' else category %}
<div class="alert alert-{{ category }}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% endblock %}
{% block page_title %}
{% if page_title %}
<h1 class="page-header">{{ page_title }}
{% block title_actions %}
{% endblock %}
</h1>
{% endif %}
{% endblock %}
{% block content %}{% endblock %}
</div>
</div>
<footer class="container text-right">
{{ config['PROJECT_NAME'] }}
</footer>
{% block tail_js %}
<script src="https://code.jquery.com/jquery-1.10.2.min.js"></script>
<script src="{{ static('js/bootstrap.min.js') }}"></script>
<script src="{{ static('js/scripts.js') }}"></script>
{% if config.get('SOCIAL_FACEBOOK') %}
<!-- Facebook SDK START -->
<script>
window.fbAsyncInit = function() {
FB.init({
appId : {{ config['SOCIAL_FACEBOOK']['consumer_key'] }},
cookie : true,
xfbml : true,
version : 'v2.3',
});
};
// Load the SDK asynchronously
(function(d, s, id) {
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) return;
js = d.createElement(s); js.id = id;
js.src = "//connect.facebook.net/en_US/sdk.js";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
</script>
<!-- Facebook SDK END -->
{% endif %}
{% endblock -%}
{%- block tail %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% set page_title = page.title %}
{% block content %}
{% if page.content %}
{{ page.content|markdown }}
{% endif %}
{% if page.subpages %}
<hr>
<ul>
{% for sub in page.subpages %}
<li><a href="{{ url_for('flatpages.show', slug=sub.slug) }}">{{ sub.title }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

4
templates/index.html Normal file
View File

@ -0,0 +1,4 @@
{% extends "base.html" %}
{% block content -%}
{%- endblock %}

View File

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% set page_title = 'Change password' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% block content %}
<form action="{{ url_for_security('change_password') }}" method="POST" class="form-horizontal col-md-6 col-md-offset-3">
{{ change_password_form.hidden_tag() }}
{{ render_field(change_password_form.password) }}
{{ render_field(change_password_form.new_password) }}
{{ render_field(change_password_form.new_password_confirm) }}
{{ render_submit() }}
</form>
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% set page_title = 'Registration' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% block content %}
<form action="{{ url_for('friends.confirm_social') }}" method="POST" class="form-horizontal col-md-6 col-md-offset-3">
{{ form.hidden_tag() }}
<div class="col-xs-9 col-xs-offset-3">
<span class="help-block">You will be registered as <strong>{{ session['failed_login_connection']['full_name'] }}</strong>.</span>
<span class="help-block">Please confirm your address.</span>
</div>
{{ render_field(form.email) }}
{{ render_submit() }}
</form>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "base.html" %}
{% set page_title = 'Password reset' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% block content %}
<form action="{{ url_for_security('forgot_password') }}" method="POST" class="form-horizontal col-md-6 col-md-offset-3">
{{ forgot_password_form.hidden_tag() }}
{{ render_field(forgot_password_form.email) }}
{{ render_submit() }}
</form>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% set page_title = 'Invitation' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% block content %}
<form action="{{ url_for('friends.invited', token=invite_token) }}" method="POST" class="form-horizontal col-md-6 col-md-offset-3">
{{ invite_form.hidden_tag() }}
{{ render_field(invite_form.password) }}
{{ render_field(invite_form.password_confirm) }}
{{ render_submit() }}
</form>
{% endblock %}

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% set page_title = 'Login' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% from "_socialhelpers.html" import social_login %}
{% block content %}
<form action="{{ url_for_security('login') }}" method="POST" class="form-horizontal col-md-6 col-md-offset-3">
{{ login_user_form.hidden_tag() }}
{{ render_field(login_user_form.email) }}
{{ render_field(login_user_form.password) }}
{{ render_field(login_user_form.remember) }}
{{ render_field(login_user_form.next) }}
<div class="form-group">
<div class="col-xs-9 col-xs-offset-3">
{{ render_submit(layout=False) }}
<a href="{{ url_for_security('forgot_password') }}" class="btn btn-default">Forgot password?</a>
</div>
</div>
</form>
<div class="col-md-3">
{{ social_login('facebook', 'Facebook') }}
</div>
{% endblock %}

View File

@ -0,0 +1,20 @@
{% extends "base.html" %}
{% set page_title = 'Registration' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% from "_socialhelpers.html" import social_register %}
{% block content %}
<div class="container-fluid">
<div class="col-md-6 col-md-offset-3">
<form action="{{ url_for_security('register') }}" method="POST" class="form-horizontal">
{{ register_user_form.hidden_tag() }}
{{ render_field(register_user_form.email) }}
{{ render_field(register_user_form.password) }}
{{ render_submit() }}
</form>
</div>
<div class="col-md-3">
{{ social_register('facebook', 'Facebook') }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% set page_title = 'Password reset' %}
{% from "_formhelpers.html" import render_field, render_submit %}
{% block content %}
<form action="{{ url_for_security('reset_password', token=reset_password_token) }}" method="POST" class="form-horizontal col-md-6 col-md-offset-3">
{{ reset_password_form.hidden_tag() }}
{{ render_field(reset_password_form.password) }}
{{ render_field(reset_password_form.password_confirm) }}
{{ render_submit() }}
</form>
{% endblock %}