From 0477400a74706c0844a408ace1f7b94642a09cb3 Mon Sep 17 00:00:00 2001 From: Piotr Dobrowolski Date: Thu, 26 Mar 2020 09:15:59 +0100 Subject: [PATCH] Boilerplate --- .gitignore | 2 + Dockerfile | 30 +++++++++++ docker-compose.yml | 19 +++++++ formity/__init__.py | 24 +++++++++ formity/admin.py | 0 formity/extensions.py | 28 ++++++++++ formity/models.py | 1 + formity/settings/__init__.py | 0 formity/settings/production.py | 8 +++ formity/utils.py | 49 +++++++++++++++++ formity/views.py | 3 ++ formity/wsgi.py | 3 ++ migrations/README | 1 + migrations/alembic.ini | 45 ++++++++++++++++ migrations/env.py | 96 ++++++++++++++++++++++++++++++++++ migrations/script.py.mako | 24 +++++++++ requirements.txt | 30 +++++++++++ 17 files changed, 363 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 formity/__init__.py create mode 100644 formity/admin.py create mode 100644 formity/extensions.py create mode 100644 formity/models.py create mode 100644 formity/settings/__init__.py create mode 100644 formity/settings/production.py create mode 100644 formity/utils.py create mode 100644 formity/views.py create mode 100644 formity/wsgi.py create mode 100644 migrations/README create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..992e8f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +.ropeproject diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7b757c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM alpine:3.11.3@sha256:ddba4d27a7ffc3f86dd6c2f92041af252a1f23a8e742c90e6e1297bfa1bc0c45 +EXPOSE 5000 +WORKDIR /usr/src/app + +RUN apk add --no-cache \ + uwsgi-python3 \ + python3 \ + libpq git + +# psycopg2 needs some extra build tools and headers. Install them and build in a +# single step in order not to pollute Docker layers +RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev postgresql-dev libffi-dev && \ + pip3 install --no-cache-dir psycopg2==2.8.4 pycparser==2.20 cffi==1.14.0 bcrypt==3.1.7 && \ + apk del --no-cache .build-deps + +COPY requirements.txt . +RUN pip3 install --no-cache-dir -r requirements.txt + +ENV FLASK_APP formity +ENV FLASK_ENV production + +COPY . . + +STOPSIGNAL SIGINT +CMD flask db upgrade && exec uwsgi --http-socket 0.0.0.0:5000 \ + --processes 4 \ + --uid uwsgi \ + --plugins python3 \ + --wsgi formity.wsgi:application \ + --touch-reload formity/wsgi.py diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1887cb1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: "3" +services: + postgres: + # postgres:9.6.17-alpine + image: postgres@sha256:c48c87e19b1c9bdc9d1de8a0f53fa1c7f91f887ecc06d0c2efd3f3425090b6c0 + volumes: + - pgdata:/var/lib/postgresql/data + environment: + - POSTGRES_PASSWORD=secret + + backend: + build: . + ports: + - 5000:5000 + volumes: + - .:/usr/src/app + +volumes: + pgdata: diff --git a/formity/__init__.py b/formity/__init__.py new file mode 100644 index 0000000..4279f34 --- /dev/null +++ b/formity/__init__.py @@ -0,0 +1,24 @@ +import flask +from formity.extensions import db, migrate, admin + +def create_app(): + app = flask.Flask( + __name__, + template_folder='../templates', + static_folder='../static', + ) + + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config.from_object('formity.settings.%s' % app.env) + + db.init_app(app) + migrate.init_app(app, db) + admin.init_app(app) + + import formity.admin + import formity.views + import formity.models + + app.register_blueprint(formity.views.bp) + + return app diff --git a/formity/admin.py b/formity/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/formity/extensions.py b/formity/extensions.py new file mode 100644 index 0000000..dd2b4f1 --- /dev/null +++ b/formity/extensions.py @@ -0,0 +1,28 @@ +from flask import current_app +import flask_sqlalchemy +import flask_migrate +import flask_admin + +from flask_admin.contrib.sqla import ModelView as BaseModelView +from flask_login import current_user + + +class SecurityMixin: + def is_accessible(self): + return current_user.is_authenticated + + def inaccessible_callback(self, name, **kwargs): + # redirect to login page if user doesn't have access + return current_app.login_manager.unauthorized() + +class IndexView(SecurityMixin, flask_admin.AdminIndexView): + pass + + +class ModelView(SecurityMixin, BaseModelView): + pass + + +db = flask_sqlalchemy.SQLAlchemy() +migrate = flask_migrate.Migrate() +admin = flask_admin.Admin(template_mode='bootstrap3', index_view=IndexView()) diff --git a/formity/models.py b/formity/models.py new file mode 100644 index 0000000..e477eed --- /dev/null +++ b/formity/models.py @@ -0,0 +1 @@ +from formity.extensions import db diff --git a/formity/settings/__init__.py b/formity/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/formity/settings/production.py b/formity/settings/production.py new file mode 100644 index 0000000..dc423c4 --- /dev/null +++ b/formity/settings/production.py @@ -0,0 +1,8 @@ +from environs import Env +env = Env() +env.read_env() + +FLASK_ADMIN_FLUID_LAYOUT = True + +SECRET_KEY = env.str('SECRET_KEY', default='randomstring') +SQLALCHEMY_DATABASE_URI = env.str('DATABASE_URI', default='postgresql+psycopg2://postgres:secret@postgres') diff --git a/formity/utils.py b/formity/utils.py new file mode 100644 index 0000000..6ecbc96 --- /dev/null +++ b/formity/utils.py @@ -0,0 +1,49 @@ +import re +import unicodedata +from sqlalchemy.orm import exc +from werkzeug.exceptions import abort + + +def get_object_or_404(model, *criterion): + try: + rv = model.query.filter(*criterion).one() + except (exc.NoResultFound, exc.MultipleResultsFound): + abort(404) + else: + return rv + + +def strip_accents(text): + """ + Strip accents from input String. + + :param text: The input string. + :type text: String. + + :returns: The processed String. + :rtype: String. + """ + try: + text = unicode(text, 'utf-8') + except (TypeError, NameError): # unicode is a default on python 3 + pass + text = unicodedata.normalize('NFD', text) + text = text.encode('ascii', 'ignore') + text = text.decode("utf-8") + return str(text) + + +def text_to_id(text): + """ + Convert input text to id. + + :param text: The input string. + :type text: String. + + :returns: The processed String. + :rtype: String. + """ + text = strip_accents(text.lower()) + text = re.sub('[ ]+', '_', text) + text = re.sub('[^0-9a-zA-Z_-]', '', text) + return text diff --git a/formity/views.py b/formity/views.py new file mode 100644 index 0000000..9a35d00 --- /dev/null +++ b/formity/views.py @@ -0,0 +1,3 @@ +from flask import Blueprint + +bp = Blueprint('main', __name__) diff --git a/formity/wsgi.py b/formity/wsgi.py new file mode 100644 index 0000000..8cbba1d --- /dev/null +++ b/formity/wsgi.py @@ -0,0 +1,3 @@ +import formity + +application = formity.create_app() diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /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 0000000..f8ed480 --- /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 0000000..9452179 --- /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', + str(current_app.extensions['migrate'].db.engine.url).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 0000000..2c01563 --- /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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3af50f0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,30 @@ +alembic==1.4.2 +blinker==1.4 +certifi==2019.11.28 +chardet==3.0.4 +click==7.1.1 +environs==7.3.1 +Flask==1.1.1 +Flask-Admin==1.5.5 +Flask-Login==0.5.0 +Flask-Migrate==2.5.3 +Flask-OAuthlib==0.9.5 +git+https://code.hackerspace.pl/informatic/flask-spaceauth#egg=Flask-SpaceAuth +Flask-SQLAlchemy==2.4.1 +idna==2.9 +itsdangerous==1.1.0 +Jinja2==2.11.1 +Mako==1.1.2 +MarkupSafe==1.1.1 +marshmallow==3.5.1 +oauthlib==2.1.0 +python-dateutil==2.8.1 +python-dotenv==0.12.0 +python-editor==1.0.4 +requests==2.23.0 +requests-oauthlib==1.3.0 +six==1.14.0 +SQLAlchemy==1.3.15 +urllib3==1.25.8 +Werkzeug==1.0.0 +WTForms==2.2.1