Commit 8b84cb56 authored by Raoul Snyman's avatar Raoul Snyman

Merge branch 'migrations' into 'master'

Use Alembic and Flask-Migrate for painless database upgrades

See merge request !1
parents e39bcad0 9aa7825c
......@@ -4,5 +4,6 @@ dist
.venv
*.conf
*.egg-info
*.sqlite
docker-compose.yaml
wsgi.py
......@@ -13,7 +13,8 @@ from wtforms.fields.html5 import IntegerField
from wtforms.validators import DataRequired
from wtforms.widgets import TextArea
from cornerstone.models import MenuLink, Page, Preacher, Sermon, Topic, User, session
from cornerstone.db import session
from cornerstone.db.models import MenuLink, Page, Preacher, Sermon, Topic, User
from cornerstone.settings import get_all_settings, get_setting, has_setting, save_setting
......
......@@ -3,12 +3,16 @@ import os
from flask import Flask
from flask_admin.menu import MenuLink
from flask_migrate import Migrate
from flask_themes2 import Themes
from flask_user import UserManager
from cornerstone.admin import admin
from cornerstone.config import config_from_file
from cornerstone.models import User, db, setup_db
from cornerstone.db import db
from cornerstone.db.migrate import get_current_head, upgrade
from cornerstone.db.models import User
from cornerstone.db.setup import setup_app
from cornerstone.views.home import home
from cornerstone.views.pages import pages
from cornerstone.views.sermons import sermons
......@@ -18,13 +22,15 @@ logging.basicConfig()
def create_app(config_file):
# Set up the app
app = Flask('cornerstone')
config_from_file(app, config_file)
if os.environ.get('THEME_PATHS'):
app.config.update({'THEME_PATHS': os.environ['THEME_PATHS']})
# Set up the extensions
Themes(app, app_identifier='cornerstone')
# Initialise various other parts of the application
db.init_app(app)
Migrate(app, db)
admin.init_app(app)
UserManager(app, db, User)
# Register blueprints
......@@ -32,8 +38,14 @@ def create_app(config_file):
app.register_blueprint(pages)
app.register_blueprint(sermons)
app.register_blueprint(uploads)
# Perform some tasks
with app.app_context():
setup_db(app)
# Set up database
current_head = get_current_head(app)
upgrade(app)
# If started with a blank database, set up the app
if not current_head:
setup_app(app)
# Set up menu shortcuts
admin.add_link(MenuLink('Back to main site', '/'))
admin.add_link(MenuLink('Logout', '/user/sign-out'))
......
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
session = db.session
def get_or_create(model, **kwargs):
instance = model.query.filter_by(**kwargs).first()
if not instance:
instance = model(**kwargs)
session.add(instance)
return instance
from pathlib import Path
from alembic.script import ScriptDirectory
from alembic.runtime.environment import EnvironmentContext
MIGRATIONS_DIR = str(Path(__file__).parent.parent / 'migrations')
def get_config_and_script(app):
"""Create a ScriptDirectory instance"""
config = app.extensions['migrate'].migrate.get_config(MIGRATIONS_DIR)
return config, ScriptDirectory.from_config(config)
def get_current_head(app):
"""Get the current head revision"""
return get_config_and_script(app)[1].get_current_head()
def upgrade(app):
"""Upgrade the database"""
config, script = get_config_and_script(app)
def _upgrade(rev, context):
return script._upgrade_revs('head', rev)
with EnvironmentContext(config, script, fn=_upgrade, as_sql=False, starting_rev=None, destination_rev='head',
tag=None):
script.run_env()
from flask_sqlalchemy import SQLAlchemy
from flask_user import UserMixin
db = SQLAlchemy()
session = db.session
def get_or_create(session, model, **kwargs):
instance = model.query.filter_by(**kwargs).first()
if not instance:
instance = model(**kwargs)
session.add(instance)
return instance
from cornerstone.db import db
sermons_topics = db.Table(
......@@ -99,48 +89,3 @@ class MenuLink(db.Model):
weight = db.Column(db.Integer, default=0)
is_enabled = db.Column(db.Boolean, default=True)
can_edit = db.Column(db.Boolean, default=True)
def setup_db(app):
"""
Set up the database
"""
# Need this to prevent a circular import
from cornerstone.settings import add_setting, has_setting, save_setting
# Create the tables
db.create_all()
# Optionally create a superuser
if app.config.get('CORNERSTONE_SUPERUSER', None) and app.config['CORNERSTONE_SUPERUSER'].get('email', None):
superuser = app.config['CORNERSTONE_SUPERUSER']
if not User.query.filter(User.email == superuser['email']).first():
user = User(
name=superuser.get('name', 'Superuser'),
email=superuser['email'],
password=app.user_manager.hash_password(superuser.get('password', 'Password1')),
)
db.session.add(user)
# Create the home page, if it doesn't exist
index_page = get_or_create(db.session, Page, slug='home')
if not index_page.title and not index_page.body:
index_page.title = 'Home'
index_page.body = '<h1>Home</h1><p>This is the home page. Edit it and replace this content with your own.</p>'
db.session.add(index_page)
# Add some settings, if they don't already exist
if not has_setting('sermons-on-home-page'):
add_setting('Show sermons on the home page', 'sermons-on-home-page', 'bool', 'home page')
save_setting('sermons-on-home-page', False)
add_setting('Number of sermons to show on the home page', 'sermons-home-page-count', 'int', 'home page')
save_setting('sermons-home-page-count', 10)
if not has_setting('pages-in-menu'):
add_setting('Include pages in menu automatically', 'pages-in-menu', 'bool', 'menu')
save_setting('pages-in-menu', True)
if not has_setting('theme'):
add_setting('Theme', 'theme', 'str', 'theme')
save_setting('theme', 'bootstrap4')
# Create some permanent menu links
if not MenuLink.query.filter_by(slug='home').first():
db.session.add(MenuLink(title='Home', slug='home', url='/', can_edit=False))
if not MenuLink.query.filter_by(slug='sermons').first():
db.session.add(MenuLink(title='Sermons', slug='sermons', url='/sermons', can_edit=False))
db.session.commit()
from cornerstone.db import db, get_or_create
from cornerstone.db.models import MenuLink, Page, User
from cornerstone.settings import add_setting, has_setting, save_setting
def setup_app(app):
"""
Set up the application the first time
"""
# Optionally create a superuser
if app.config.get('CORNERSTONE_SUPERUSER', None) and app.config['CORNERSTONE_SUPERUSER'].get('email', None):
superuser = app.config['CORNERSTONE_SUPERUSER']
if not User.query.filter(User.email == superuser['email']).first():
user = User(
name=superuser.get('name', 'Superuser'),
email=superuser['email'],
password=app.user_manager.hash_password(superuser.get('password', 'Password1')),
)
db.session.add(user)
# Create the home page, if it doesn't exist
index_page = get_or_create(Page, slug='home')
if not index_page.title and not index_page.body:
index_page.title = 'Home'
index_page.body = '<h1>Home</h1><p>This is the home page. Edit it and replace this content with your own.</p>'
db.session.add(index_page)
# Add some settings, if they don't already exist
if not has_setting('sermons-on-home-page'):
add_setting('Show sermons on the home page', 'sermons-on-home-page', 'bool', 'home page')
save_setting('sermons-on-home-page', False)
add_setting('Number of sermons to show on the home page', 'sermons-home-page-count', 'int', 'home page')
save_setting('sermons-home-page-count', 10)
if not has_setting('pages-in-menu'):
add_setting('Include pages in menu automatically', 'pages-in-menu', 'bool', 'menu')
save_setting('pages-in-menu', True)
if not has_setting('theme'):
add_setting('Theme', 'theme', 'str', 'theme')
save_setting('theme', 'bootstrap4')
# Create some permanent menu links
if not MenuLink.query.filter_by(slug='home').first():
db.session.add(MenuLink(title='Home', slug='home', url='/', can_edit=False))
if not MenuLink.query.filter_by(slug='sermons').first():
db.session.add(MenuLink(title='Sermons', slug='sermons', url='/sermons', can_edit=False))
db.session.commit()
Generic single-database configuration.
\ No newline at end of file
# 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
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()
"""${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"}
"""empty message
Revision ID: b2d74d4c0e01
Revises:
Create Date: 2019-10-05 22:20:32.016829
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b2d74d4c0e01'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('menulinks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('url', sa.String(length=255), nullable=False),
sa.Column('weight', sa.Integer(), nullable=True),
sa.Column('is_enabled', sa.Boolean(), nullable=True),
sa.Column('can_edit', sa.Boolean(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('slug')
)
op.create_table('pages',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('slug', sa.String(length=255), nullable=False),
sa.Column('body', sa.Text(), nullable=True),
sa.Column('weight', sa.Integer(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_table('preachers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('settings',
sa.Column('key', sa.String(length=255), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('group', sa.String(length=255), nullable=True),
sa.Column('value', sa.Text(), nullable=True),
sa.Column('type', sa.String(length=20), nullable=False),
sa.Column('allowed_values', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('key')
)
op.create_table('topics',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('password', sa.String(length=255), server_default='', nullable=False),
sa.Column('is_active', sa.Boolean(), server_default='1', nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
op.create_table('sermons',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('preacher_id', sa.Integer(), nullable=True),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('scripture', sa.String(length=255), nullable=False),
sa.Column('simplecast_id', sa.String(length=50), nullable=True),
sa.Column('date', sa.Date(), nullable=False),
sa.ForeignKeyConstraint(['preacher_id'], ['preachers.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('sermons_topics',
sa.Column('sermon_id', sa.Integer(), nullable=False),
sa.Column('topic_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['sermon_id'], ['sermons.id'], ),
sa.ForeignKeyConstraint(['topic_id'], ['topics.id'], ),
sa.PrimaryKeyConstraint('sermon_id', 'topic_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('sermons_topics')
op.drop_table('sermons')
op.drop_table('users')
op.drop_table('topics')
op.drop_table('settings')
op.drop_table('preachers')
op.drop_table('pages')
op.drop_table('menulinks')
# ### end Alembic commands ###
import json
from cornerstone.models import Setting, db
from cornerstone.db import db
from cornerstone.db.models import Setting
def has_setting(key):
......
from flask_themes2 import get_theme, render_theme_template
from cornerstone.models import MenuLink
from cornerstone.db.models import MenuLink
from cornerstone.settings import get_setting
......
from flask import Blueprint
from jinja2 import TemplateNotFound
from cornerstone.models import Page, Sermon
from cornerstone.db.models import Page, Sermon
from cornerstone.settings import get_setting
from cornerstone.theming import render
......
from flask import Blueprint
from jinja2 import TemplateNotFound
from cornerstone.models import Page
from cornerstone.db.models import Page
from cornerstone.theming import render
......
......@@ -2,7 +2,7 @@ import logging
from flask import Blueprint, current_app, request
from cornerstone.models import Sermon
from cornerstone.db.models import Sermon
from cornerstone.sphinxapi import SphinxClient
from cornerstone.theming import render
......
......@@ -30,6 +30,7 @@ setup(
install_requires=[
'Flask',
'Flask-Admin',
'Flask-Migrate',
'Flask-SQLAlchemy',
'Flask-User',
'Flask-Themes2',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment