diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 5c3a9d7..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,27 +70,11 @@ 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) @@ -103,7 +85,7 @@ def create_app(test_config=None): configure_app(app, test_config) db.init_app(app) migrate.init_app(app, db, compare_type=True) - __load_plugins(app) + load_plugins(app) @app.route("/", methods=["GET"]) def __get_state(): diff --git a/flaschengeist/database.py b/flaschengeist/database.py index 560f7ed..ccafd8d 100644 --- a/flaschengeist/database.py +++ b/flaschengeist/database.py @@ -24,9 +24,16 @@ migrate = Migrate() 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 = [str(p.migrations_path) for p in current_app.config["FG_PLUGINS"].values() if p and p.migrations_path] + 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") diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index a2dc6c9..edfe243 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -5,8 +5,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 @@ -121,6 +121,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 @@ -173,6 +184,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! @@ -235,7 +251,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, @@ -267,7 +283,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/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/utils/plugin.py b/flaschengeist/utils/plugin.py new file mode 100644 index 0000000..b37e582 --- /dev/null +++ b/flaschengeist/utils/plugin.py @@ -0,0 +1,46 @@ +"""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.") + 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