From 84addcbd46e7b77d1a953d3d181ba1d9d4271960 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Sun, 19 Dec 2021 22:11:57 +0100 Subject: [PATCH] feat(db): Add database migration support, implements #19 Migrations allow us to keep track of database changes and upgrading databases if needed. * Add initial migrations for core Flaschengeist * Add migrations to balance * Add migrations to pricelist * Skip plugins with not satisfied dependencies. --- flaschengeist/app.py | 33 +--- flaschengeist/database.py | 33 ++++ flaschengeist/plugins/__init__.py | 22 ++- flaschengeist/plugins/auth/__init__.py | 4 +- flaschengeist/plugins/auth_ldap/__init__.py | 2 +- flaschengeist/plugins/auth_plain/__init__.py | 2 + flaschengeist/plugins/balance/__init__.py | 3 + .../f07df84f7a95_initial_balance_migration.py | 47 ++++++ flaschengeist/plugins/message_mail.py | 2 +- flaschengeist/plugins/pricelist/__init__.py | 2 + ...d9d306be676_initial_pricelist_migration.py | 141 ++++++++++++++++++ flaschengeist/utils/plugin.py | 49 ++++++ migrations/alembic.ini | 52 +++++++ migrations/env.py | 73 +++++++++ migrations/script.py.mako | 25 ++++ .../d3026757c7cb_initial_migration.py | 141 ++++++++++++++++++ setup.cfg | 6 +- 17 files changed, 602 insertions(+), 35 deletions(-) create mode 100644 flaschengeist/plugins/balance/migrations/f07df84f7a95_initial_balance_migration.py create mode 100644 flaschengeist/plugins/pricelist/migrations/7d9d306be676_initial_pricelist_migration.py create mode 100644 flaschengeist/utils/plugin.py create mode 100644 migrations/alembic.ini create mode 100644 migrations/env.py create mode 100644 migrations/script.py.mako create mode 100644 migrations/versions/d3026757c7cb_initial_migration.py diff --git a/flaschengeist/app.py b/flaschengeist/app.py index f2f1664..b6f87e1 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -1,6 +1,6 @@ import enum -from flask import Flask, current_app +from flask import Flask from flask_cors import CORS from datetime import datetime, date from flask.json import JSONEncoder, jsonify @@ -11,7 +11,6 @@ from werkzeug.exceptions import HTTPException from flaschengeist import logger from flaschengeist.utils.hook import Hook from flaschengeist.plugins import AuthPlugin -from flaschengeist.controller import roleController from flaschengeist.config import config, configure_app @@ -37,10 +36,9 @@ class CustomJSONEncoder(JSONEncoder): @Hook("plugins.loaded") -def __load_plugins(app): - logger.debug("Search for plugins") - +def load_plugins(app: Flask): app.config["FG_PLUGINS"] = {} + for entry_point in entry_points(group="flaschengeist.plugins"): logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})") @@ -72,37 +70,22 @@ def __load_plugins(app): else: logger.debug(f"Skip disabled plugin {entry_point.name}") if "FG_AUTH_BACKEND" not in app.config: - logger.error("No authentication plugin configured or authentication plugin not found") + logger.fatal("No authentication plugin configured or authentication plugin not found") raise RuntimeError("No authentication plugin configured or authentication plugin not found") -@Hook("plugins.installed") -def install_all(): - from flaschengeist.database import db - - db.create_all() - db.session.commit() - for name, plugin in current_app.config["FG_PLUGINS"].items(): - if not plugin: - logger.debug(f"Skip disabled plugin: {name}") - continue - logger.info(f"Install plugin {name}") - plugin.install() - if plugin.permissions: - roleController.create_permissions(plugin.permissions) - - -def create_app(test_config=None): +def create_app(test_config=None, cli=False): app = Flask("flaschengeist") app.json_encoder = CustomJSONEncoder CORS(app) with app.app_context(): - from flaschengeist.database import db + from flaschengeist.database import db, migrate configure_app(app, test_config) db.init_app(app) - __load_plugins(app) + migrate.init_app(app, db, compare_type=True) + load_plugins(app) @app.route("/", methods=["GET"]) def __get_state(): diff --git a/flaschengeist/database.py b/flaschengeist/database.py index ebda993..ccafd8d 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -1,3 +1,6 @@ +import os +from flask import current_app +from flask_migrate import Migrate from flask_sqlalchemy import SQLAlchemy from sqlalchemy import MetaData @@ -14,6 +17,36 @@ metadata = MetaData( db = SQLAlchemy(metadata=metadata) +migrate = Migrate() + + +@migrate.configure +def configure_alembic(config): + """Alembic configuration hook + + Inject all migrations paths into the ``version_locations`` config option. + This includes even disabled plugins, as simply disabling a plugin without + uninstall can break the alembic version management. + """ + import inspect, pathlib + from flaschengeist.utils.plugin import get_plugins + + # Load migration paths from plugins + migrations = [(pathlib.Path(inspect.getfile(p)).parent / "migrations") for p in get_plugins()] + migrations = [str(m.resolve()) for m in migrations if m.exists()] + if len(migrations) > 0: + # Get configured paths + paths = config.get_main_option("version_locations") + # Get configured path seperator + sep = config.get_main_option("version_path_separator", "os") + if paths: + # Insert configured paths at the front, before plugin migrations + migrations.insert(0, config.get_main_option("version_locations")) + sep = os.pathsep if sep == "os" else " " if sep == "space" else sep + # write back seperator (we changed it if neither seperator nor locations were specified) + config.set_main_option("version_path_separator", sep) + config.set_main_option("version_locations", sep.join(migrations)) + return config def case_sensitive(s): diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index d655510..7a01939 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -4,8 +4,8 @@ """ from importlib_metadata import Distribution, EntryPoint -from werkzeug.datastructures import FileStorage from werkzeug.exceptions import MethodNotAllowed, NotFound +from werkzeug.datastructures import FileStorage from flaschengeist.models.user import _Avatar, User from flaschengeist.utils.hook import HookBefore, HookAfter @@ -96,6 +96,17 @@ class Plugin: """ pass + def uninstall(self): + """Uninstall routine + + If the plugin has custom database tables, make sure to remove them. + This can be either done by downgrading the plugin *head* to the *base*. + Or use custom migrations for the uninstall and *stamp* some version. + + Is always called with Flask application context. + """ + pass + def get_setting(self, name: str, **kwargs): """Get plugin setting from database @@ -148,6 +159,11 @@ class Plugin: class AuthPlugin(Plugin): + """Base class for all authentification plugins + + See also `Plugin` + """ + def login(self, user, pw): """Login routine, MUST BE IMPLEMENTED! @@ -210,7 +226,7 @@ class AuthPlugin(Plugin): """ raise MethodNotAllowed - def get_avatar(self, user: User) -> _Avatar: + def get_avatar(self, user): """Retrieve avatar for given user (if supported by auth backend) Default behavior is to use native Image objects, @@ -242,7 +258,7 @@ class AuthPlugin(Plugin): user.avatar_ = imageController.upload_image(file) - def delete_avatar(self, user: User): + def delete_avatar(self, user): """Delete the avatar for given user (if supported by auth backend) Default behavior is to use the imageController and native Image objects. diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index 9c08463..66d77be 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -13,8 +13,8 @@ from flaschengeist.controller import sessionController, userController class AuthRoutePlugin(Plugin): - name = "auth" - blueprint = Blueprint(name, __name__) + id = "dev.flaschengeist.auth" + blueprint = Blueprint("auth", __name__) @AuthRoutePlugin.blueprint.route("/auth", methods=["POST"]) diff --git a/flaschengeist/plugins/auth_ldap/__init__.py b/flaschengeist/plugins/auth_ldap/__init__.py index 7aa8fb6..ef8ecb1 100644 --- a/flaschengeist/plugins/auth_ldap/__init__.py +++ b/flaschengeist/plugins/auth_ldap/__init__.py @@ -18,7 +18,7 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated class AuthLDAP(AuthPlugin): def __init__(self, entry_point, config): - super().__init__(entry_point) + super().__init__(entry_point, config) app.config.update( LDAP_SERVER=config.get("host", "localhost"), LDAP_PORT=config.get("port", 389), diff --git a/flaschengeist/plugins/auth_plain/__init__.py b/flaschengeist/plugins/auth_plain/__init__.py index 10d72d3..e73b98c 100644 --- a/flaschengeist/plugins/auth_plain/__init__.py +++ b/flaschengeist/plugins/auth_plain/__init__.py @@ -14,6 +14,8 @@ from flaschengeist import logger class AuthPlain(AuthPlugin): + id = "auth_plain" + def install(self): plugins_installed(self.post_install) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index c430398..3678ded 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,6 +3,7 @@ Extends users plugin with balance functions """ +import pathlib from flask import Blueprint, current_app from werkzeug.local import LocalProxy from werkzeug.exceptions import NotFound @@ -66,6 +67,8 @@ class BalancePlugin(Plugin): self.blueprint = blueprint + self.migrations_path = (pathlib.Path(__file__).parent / "migrations").resolve() + @plugins_loaded def post_loaded(*args, **kwargs): if config.get("allow_service_debit", False) and "events" in current_app.config["FG_PLUGINS"]: diff --git a/flaschengeist/plugins/balance/migrations/f07df84f7a95_initial_balance_migration.py b/flaschengeist/plugins/balance/migrations/f07df84f7a95_initial_balance_migration.py new file mode 100644 index 0000000..ecde05e --- /dev/null +++ b/flaschengeist/plugins/balance/migrations/f07df84f7a95_initial_balance_migration.py @@ -0,0 +1,47 @@ +"""Initial balance migration + +Revision ID: f07df84f7a95 +Revises: +Create Date: 2021-12-19 21:12:53.192267 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "f07df84f7a95" +down_revision = None +branch_labels = ("balance",) +depends_on = "d3026757c7cb" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "balance_transaction", + sa.Column("receiver_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("sender_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("author_id", flaschengeist.models.Serial(), nullable=False), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False), + sa.Column("amount", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False), + sa.Column("reversal_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["author_id"], ["user.id"], name=op.f("fk_balance_transaction_author_id_user")), + sa.ForeignKeyConstraint(["receiver_id"], ["user.id"], name=op.f("fk_balance_transaction_receiver_id_user")), + sa.ForeignKeyConstraint( + ["reversal_id"], + ["balance_transaction.id"], + name=op.f("fk_balance_transaction_reversal_id_balance_transaction"), + ), + sa.ForeignKeyConstraint(["sender_id"], ["user.id"], name=op.f("fk_balance_transaction_sender_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_balance_transaction")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("balance_transaction") + # ### end Alembic commands ### diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index 3a9502a..2fe6a76 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -13,7 +13,7 @@ from . import Plugin class MailMessagePlugin(Plugin): def __init__(self, entry_point, config): - super().__init__(entry_point) + super().__init__(entry_point, config) self.server = config["SERVER"] self.port = config["PORT"] self.user = config["USER"] diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index d06af7f..3f45351 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -1,5 +1,6 @@ """Pricelist plugin""" +import pathlib from flask import Blueprint, jsonify, request, current_app from werkzeug.local import LocalProxy from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized @@ -26,6 +27,7 @@ class PriceListPlugin(Plugin): def __init__(self, entry_point, config=None): super().__init__(entry_point, config) self.blueprint = blueprint + self.migrations_path = (pathlib.Path(__file__).parent / "migrations").resolve() config = {"discount": 0} config.update(config) diff --git a/flaschengeist/plugins/pricelist/migrations/7d9d306be676_initial_pricelist_migration.py b/flaschengeist/plugins/pricelist/migrations/7d9d306be676_initial_pricelist_migration.py new file mode 100644 index 0000000..c9da61e --- /dev/null +++ b/flaschengeist/plugins/pricelist/migrations/7d9d306be676_initial_pricelist_migration.py @@ -0,0 +1,141 @@ +"""Initial pricelist migration + +Revision ID: 7d9d306be676 +Revises: +Create Date: 2021-12-19 21:43:30.203811 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "7d9d306be676" +down_revision = None +branch_labels = ("pricelist",) +depends_on = "d3026757c7cb" + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "drink_extra_ingredient", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=False), + sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_extra_ingredient")), + sa.UniqueConstraint("name", name=op.f("uq_drink_extra_ingredient_name")), + ) + op.create_table( + "drink_tag", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=False), + sa.Column("color", sa.String(length=7), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_tag")), + sa.UniqueConstraint("name", name=op.f("uq_drink_tag_name")), + ) + op.create_table( + "drink_type", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_type")), + sa.UniqueConstraint("name", name=op.f("uq_drink_type_name")), + ) + op.create_table( + "drink", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("article_id", sa.String(length=64), nullable=True), + sa.Column("package_size", sa.Integer(), nullable=True), + sa.Column("name", sa.String(length=60), nullable=False), + sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.Column("cost_per_volume", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True), + sa.Column("cost_per_package", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True), + sa.Column("receipt", sa.PickleType(), nullable=True), + sa.Column("type_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("image_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["image_id"], ["image.id"], name=op.f("fk_drink_image_id_image")), + sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_type_id_drink_type")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink")), + ) + op.create_table( + "drink_ingredient", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False), + sa.Column("ingredient_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["ingredient_id"], ["drink.id"], name=op.f("fk_drink_ingredient_ingredient_id_drink")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient")), + ) + op.create_table( + "drink_price_volume", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_price_volume_drink_id_drink")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price_volume")), + ) + op.create_table( + "drink_x_tag", + sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("tag_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_tag_drink_id_drink")), + sa.ForeignKeyConstraint(["tag_id"], ["drink_tag.id"], name=op.f("fk_drink_x_tag_tag_id_drink_tag")), + ) + op.create_table( + "drink_x_type", + sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("type_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_type_drink_id_drink")), + sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_x_type_type_id_drink_type")), + ) + op.create_table( + "drink_ingredient_association", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("_drink_ingredient_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("_extra_ingredient_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint( + ["_drink_ingredient_id"], + ["drink_ingredient.id"], + name=op.f("fk_drink_ingredient_association__drink_ingredient_id_drink_ingredient"), + ), + sa.ForeignKeyConstraint( + ["_extra_ingredient_id"], + ["drink_extra_ingredient.id"], + name=op.f("fk_drink_ingredient_association__extra_ingredient_id_drink_extra_ingredient"), + ), + sa.ForeignKeyConstraint( + ["volume_id"], + ["drink_price_volume.id"], + name=op.f("fk_drink_ingredient_association_volume_id_drink_price_volume"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient_association")), + ) + op.create_table( + "drink_price", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True), + sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("public", sa.Boolean(), nullable=True), + sa.Column("description", sa.String(length=30), nullable=True), + sa.ForeignKeyConstraint( + ["volume_id"], ["drink_price_volume.id"], name=op.f("fk_drink_price_volume_id_drink_price_volume") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("drink_price") + op.drop_table("drink_ingredient_association") + op.drop_table("drink_x_type") + op.drop_table("drink_x_tag") + op.drop_table("drink_price_volume") + op.drop_table("drink_ingredient") + op.drop_table("drink") + op.drop_table("drink_type") + op.drop_table("drink_tag") + op.drop_table("drink_extra_ingredient") + # ### end Alembic commands ### diff --git a/flaschengeist/utils/plugin.py b/flaschengeist/utils/plugin.py new file mode 100644 index 0000000..56f0643 --- /dev/null +++ b/flaschengeist/utils/plugin.py @@ -0,0 +1,49 @@ +"""Plugin utils + +Utilities for handling Flaschengeist plugins +""" + +import pkg_resources +from flaschengeist import logger +from flaschengeist.plugins import Plugin + + +def get_plugins() -> list[type[Plugin]]: + """Get all installed plugins for Flaschengeist + + Returns: + list of classes implementing `flaschengeist.plugins.Plugin` + """ + logger.debug("Search for plugins") + + plugins = [] + for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"): + try: + logger.debug(f"Found plugin: >{entry_point.name}<") + plugin_class = entry_point.load() + if issubclass(plugin_class, Plugin): + plugins.append(plugin_class) + except TypeError: + logger.error(f"Invalid entry point for plugin {entry_point.name} found.") + except pkg_resources.DistributionNotFound: + logger.warn(f"Requirements not fulfilled for {entry_point.name}") + logger.debug("DistributionNotFound", exc_info=True) + return plugins + + +def plugin_version(plugin: type[Plugin]) -> str: + """Get version of plugin + + Returns the version of a plugin, if plugin does not set the + version property, the version of the package providing the + plugin is taken. + + Args: + plugin: Plugin or Plugin class + + Returns: + Version as string + """ + if plugin.version: + return plugin.version + return pkg_resources.get_distribution(plugin.__module__.split(".", 1)[0]).version diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..e1023e2 --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,52 @@ +# 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 + +version_path_separator = os +version_locations = %(here)s/versions + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[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 + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[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..f2bf297 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,73 @@ +import logging +from logging.config import fileConfig +from flask import current_app +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") + +config.set_main_option("sqlalchemy.url", str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%")) +target_metadata = current_app.extensions["migrate"].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 = 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 = current_app.extensions["migrate"].db.get_engine() + + 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..a6f4fdf --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist +${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/d3026757c7cb_initial_migration.py b/migrations/versions/d3026757c7cb_initial_migration.py new file mode 100644 index 0000000..23f55b5 --- /dev/null +++ b/migrations/versions/d3026757c7cb_initial_migration.py @@ -0,0 +1,141 @@ +"""Initial migration. + +Revision ID: d3026757c7cb +Revises: +Create Date: 2021-12-19 20:34:34.122576 + +""" +from alembic import op +import sqlalchemy as sa +import flaschengeist + + +# revision identifiers, used by Alembic. +revision = "d3026757c7cb" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "image", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("filename_", sa.String(length=127), nullable=False), + sa.Column("mimetype_", sa.String(length=30), nullable=False), + sa.Column("thumbnail_", sa.String(length=127), nullable=True), + sa.Column("path_", sa.String(length=127), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_image")), + ) + op.create_table( + "permission", + sa.Column("name", sa.String(length=30), nullable=True), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")), + sa.UniqueConstraint("name", name=op.f("uq_permission_name")), + ) + op.create_table( + "plugin_setting", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("plugin", sa.String(length=30), nullable=True), + sa.Column("name", sa.String(length=30), nullable=False), + sa.Column("value", sa.PickleType(), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")), + ) + op.create_table( + "role", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=True), + sa.PrimaryKeyConstraint("id", name=op.f("pk_role")), + sa.UniqueConstraint("name", name=op.f("uq_role_name")), + ) + op.create_table( + "role_x_permission", + sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("permission_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint( + ["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission") + ), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")), + ) + op.create_table( + "user", + sa.Column("userid", sa.String(length=30), nullable=False), + sa.Column("display_name", sa.String(length=30), nullable=True), + sa.Column("firstname", sa.String(length=50), nullable=False), + sa.Column("lastname", sa.String(length=50), nullable=False), + sa.Column("deleted", sa.Boolean(), nullable=True), + sa.Column("birthday", sa.Date(), nullable=True), + sa.Column("mail", sa.String(length=60), nullable=True), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("avatar", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), + sa.UniqueConstraint("userid", name=op.f("uq_user_userid")), + ) + op.create_table( + "notification", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("plugin", sa.String(length=127), nullable=False), + sa.Column("text", sa.Text(), nullable=True), + sa.Column("data", sa.PickleType(), nullable=True), + sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False), + sa.Column("user_id", flaschengeist.models.Serial(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_notification_user_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")), + ) + op.create_table( + "password_reset", + sa.Column("user", flaschengeist.models.Serial(), nullable=False), + sa.Column("token", sa.String(length=32), nullable=True), + sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), + sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")), + sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")), + ) + op.create_table( + "session", + sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True), + sa.Column("token", sa.String(length=32), nullable=True), + sa.Column("lifetime", sa.Integer(), nullable=True), + sa.Column("browser", sa.String(length=30), nullable=True), + sa.Column("platform", sa.String(length=30), nullable=True), + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_session")), + sa.UniqueConstraint("token", name=op.f("uq_session_token")), + ) + op.create_table( + "user_attribute", + sa.Column("id", flaschengeist.models.Serial(), nullable=False), + sa.Column("user", flaschengeist.models.Serial(), nullable=False), + sa.Column("name", sa.String(length=30), nullable=True), + sa.Column("value", sa.PickleType(), nullable=True), + sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_attribute")), + ) + op.create_table( + "user_x_role", + sa.Column("user_id", flaschengeist.models.Serial(), nullable=True), + sa.Column("role_id", flaschengeist.models.Serial(), nullable=True), + sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")), + sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("user_x_role") + op.drop_table("user_attribute") + op.drop_table("session") + op.drop_table("password_reset") + op.drop_table("notification") + op.drop_table("user") + op.drop_table("role_x_permission") + op.drop_table("role") + op.drop_table("plugin_setting") + op.drop_table("permission") + op.drop_table("image") + # ### end Alembic commands ### diff --git a/setup.cfg b/setup.cfg index c18feda..770ece4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -22,16 +22,16 @@ include_package_data = True python_requires = >=3.9 packages = find: install_requires = - Flask >= 2.0 + Flask>=2.0 Pillow>=8.4.0 flask_cors + flask_migrate>=3.1.0 flask_sqlalchemy>=2.5 # Importlib requirement can be dropped when python requirement is >= 3.10 importlib_metadata>=4.3 sqlalchemy>=1.4.26 toml - werkzeug - + werkzeug >= 2.0 [options.extras_require] argon = argon2-cffi