diff --git a/README.rst b/README.rst index 3a25de540491bcaff07888f4a2671912d554dbec..6b1d3fd0fbce32a9403b8be932f122514e2ede3c 100644 --- a/README.rst +++ b/README.rst @@ -66,7 +66,13 @@ configuration: Do replace ``SECRET_KEY`` with a unique, random value; and customise ``SQLALCHEMY_DATABASE_URI`` according to your environment. See `SQLAlchemy documentation`_ for details. You may need to create a database in your database -server beforehand. Unfortunately, SQLite support is broken at the moment. +server beforehand. + +Run database migrations: + +.. code:: shell-session + + % pdm run migrate To start the development server: diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..358051bb3468d4340e3517f5a1c54f2d0ede1829 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,47 @@ +[alembic] +script_location = schilder2000/migrations + +[post_write_hooks] +hooks = ruff ruff_format + +ruff.type = exec +ruff.executable = ruff +ruff.options = check --fix REVISION_SCRIPT_FILENAME + +ruff_format.type = exec +ruff_format.executable = ruff +ruff_format.options = format REVISION_SCRIPT_FILENAME + +[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/pdm.lock b/pdm.lock index 2381896c79415f6ad85b5531faf6084472253447..4e10e7c2f4520161b87e8efad7608ff0bbf9cd17 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "auth-ldap", "auth-oauth", "auth-saml", "db-mysql", "db-postgres", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:1af461aea0170421afe47c809cb315739413db60f16b0d60a7d38d91ddf457ca" +content_hash = "sha256:0fe1b318475e65a03e58297779bfe8676d845960ce808dc040660179751c3dd3" [[metadata.targets]] requires_python = ">=3.12" @@ -84,6 +84,24 @@ files = [ {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, ] +[[package]] +name = "alembic" +version = "1.13.2" +requires_python = ">=3.8" +summary = "A database migration tool for SQLAlchemy." +groups = ["default"] +dependencies = [ + "Mako", + "SQLAlchemy>=1.3.0", + "importlib-metadata; python_version < \"3.9\"", + "importlib-resources; python_version < \"3.9\"", + "typing-extensions>=4", +] +files = [ + {file = "alembic-1.13.2-py3-none-any.whl", hash = "sha256:6b8733129a6224a9a711e17c99b08462dbf7cc9670ba8f2e2ae9af860ceb1953"}, + {file = "alembic-1.13.2.tar.gz", hash = "sha256:1ff0ae32975f4fd96028c39ed9bb3c867fe3af956bd7bb37343b54c9fe7445ef"}, +] + [[package]] name = "arrow" version = "1.3.0" @@ -856,6 +874,20 @@ files = [ {file = "lxml-5.3.0.tar.gz", hash = "sha256:4e109ca30d1edec1ac60cdbe341905dc3b8f55b16855e03a54aaf59e51ec8c6f"}, ] +[[package]] +name = "mako" +version = "1.3.5" +requires_python = ">=3.8" +summary = "A super-fast templating language that borrows the best ideas from the existing templating languages." +groups = ["default"] +dependencies = [ + "MarkupSafe>=0.9.2", +] +files = [ + {file = "Mako-1.3.5-py3-none-any.whl", hash = "sha256:260f1dbc3a519453a9c856dedfe4beb4e50bd5a26d96386cb6c80856556bb91a"}, + {file = "Mako-1.3.5.tar.gz", hash = "sha256:48dbc20568c1d276a2698b36d968fa76161bf127194907ea6fc594fa81f943bc"}, +] + [[package]] name = "markdown-it-py" version = "3.0.0" diff --git a/pyproject.toml b/pyproject.toml index 2e3f528adc2a007764cf408b4f722a4448b87929..1f8f77ec18ee8f5d18fb8630e975ea97cd10f6c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "python-webpack-boilerplate~=1.0", "pyipp @ git+https://github.com/ctalkington/python-ipp", "Flask-Multipass~=0.5", + "alembic~=1.13", ] [project.optional-dependencies] @@ -50,6 +51,7 @@ dev = [ [tool.pdm.scripts] serve = "flask -A schilder2000 run --debug" +migrate = "flask -A schilder2000 alembic upgrade head" [tool.pdm.build] includes = [ diff --git a/schilder2000/__init__.py b/schilder2000/__init__.py index a4c9fa3c280fa85c31ba2ee7b03563c4a1572290..41757ddc8b48e1da6c1735006125dd64a4be41d7 100644 --- a/schilder2000/__init__.py +++ b/schilder2000/__init__.py @@ -5,6 +5,7 @@ from flask_sqlalchemy import SQLAlchemy from flask_wtf.csrf import CSRFProtect from webpack_boilerplate.config import setup_jinja2_ext +from . import cli from .helpers import Flask, identity_handler, require_login @@ -17,14 +18,13 @@ def create_app(): app = Flask(__name__, instance_relative_config=True) app.config.from_pyfile("config.py") + app.cli.add_command(cli.alembic) + db.init_app(app) # Ignore linter error unused export: we need to import the models to # register them with SQLAlchemy from . import models # noqa: F401 - with app.app_context(): - db.create_all() - csrf.init_app(app) if app.config["REQUIRE_LOGIN"]: diff --git a/schilder2000/cli.py b/schilder2000/cli.py new file mode 100644 index 0000000000000000000000000000000000000000..46199455a881de660a4d5fbdb4572d7efe516490 --- /dev/null +++ b/schilder2000/cli.py @@ -0,0 +1,18 @@ +import sys +from os.path import basename + +import click +from flask.cli import with_appcontext +from alembic import config + + +@click.command( + context_settings={"ignore_unknown_options": True, "help_option_names": []} +) +@click.argument("argv", nargs=-1) +@with_appcontext +def alembic(argv): + """Wrap the alembic CLI tool.""" + prog = " ".join([basename(sys.argv[0]), sys.argv[1]]) + cli = config.CommandLine(prog=prog) + cli.main(argv=argv) diff --git a/schilder2000/migrations/__init__.py b/schilder2000/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/schilder2000/migrations/env.py b/schilder2000/migrations/env.py new file mode 100644 index 0000000000000000000000000000000000000000..003ad7767140bf798e00614b20d292f9fed9b2fa --- /dev/null +++ b/schilder2000/migrations/env.py @@ -0,0 +1,83 @@ +from logging.config import fileConfig + +from alembic import context + +from flask import current_app as app + +try: + app.app_context() +except RuntimeError: + import schilder2000 + + app = schilder2000.create_app() + + +db = app.extensions["sqlalchemy"] + +# Provide access to the values within alembic.ini. +config = context.config + +# Sets up Python logging. +fileConfig(config.config_file_name) + +# Sets up metadata for autogenerate support, +target_metadata = db.metadata + + +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 = app.config["SQLALCHEMY_DATABASE_URI"] + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + 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. + """ + + # If you use Alembic revision's --autogenerate flag this function will + # prevent Alembic from creating an empty migration file if nothing changed. + # Source: https://alembic.sqlalchemy.org/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if config.cmd_opts.autogenerate: + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + + with app.app_context(): + connectable = db.engine + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + process_revision_directives=process_revision_directives, + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/schilder2000/migrations/script.py.mako b/schilder2000/migrations/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..2c0156303a8df3ffdc9de87765bf801bf6bea4a5 --- /dev/null +++ b/schilder2000/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/schilder2000/migrations/versions/7a38fc23dda5_initial_revision.py b/schilder2000/migrations/versions/7a38fc23dda5_initial_revision.py new file mode 100644 index 0000000000000000000000000000000000000000..e1808a506117168707885c48f7bd5b3338799cbb --- /dev/null +++ b/schilder2000/migrations/versions/7a38fc23dda5_initial_revision.py @@ -0,0 +1,33 @@ +"""Initial revision + +Revision ID: 7a38fc23dda5 +Revises: +Create Date: 2024-09-16 15:37:03.537826 + +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "7a38fc23dda5" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "schild", + sa.Column("ident", sa.Uuid(), nullable=False), + sa.Column("title", sa.String(length=31), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.Column("image", sa.String(length=255), nullable=True), + sa.Column("template", sa.String(length=255), nullable=False), + sa.PrimaryKeyConstraint("ident"), + ) + + +def downgrade(): + op.drop_table("schild")