feat(plugins): Identify plugins by id, migrations must be provided at defined location, add utils for plugin functions
This commit is contained in:
parent
f5c7e59162
commit
4248405462
|
@ -1,6 +1,6 @@
|
||||||
import enum
|
import enum
|
||||||
|
|
||||||
from flask import Flask, current_app
|
from flask import Flask
|
||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from flask.json import JSONEncoder, jsonify
|
from flask.json import JSONEncoder, jsonify
|
||||||
|
@ -11,7 +11,6 @@ from werkzeug.exceptions import HTTPException
|
||||||
from flaschengeist import logger
|
from flaschengeist import logger
|
||||||
from flaschengeist.utils.hook import Hook
|
from flaschengeist.utils.hook import Hook
|
||||||
from flaschengeist.plugins import AuthPlugin
|
from flaschengeist.plugins import AuthPlugin
|
||||||
from flaschengeist.controller import roleController
|
|
||||||
from flaschengeist.config import config, configure_app
|
from flaschengeist.config import config, configure_app
|
||||||
|
|
||||||
|
|
||||||
|
@ -37,10 +36,9 @@ class CustomJSONEncoder(JSONEncoder):
|
||||||
|
|
||||||
|
|
||||||
@Hook("plugins.loaded")
|
@Hook("plugins.loaded")
|
||||||
def __load_plugins(app):
|
def load_plugins(app: Flask):
|
||||||
logger.debug("Search for plugins")
|
|
||||||
|
|
||||||
app.config["FG_PLUGINS"] = {}
|
app.config["FG_PLUGINS"] = {}
|
||||||
|
|
||||||
for entry_point in entry_points(group="flaschengeist.plugins"):
|
for entry_point in entry_points(group="flaschengeist.plugins"):
|
||||||
logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})")
|
logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})")
|
||||||
|
|
||||||
|
@ -72,27 +70,11 @@ def __load_plugins(app):
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Skip disabled plugin {entry_point.name}")
|
logger.debug(f"Skip disabled plugin {entry_point.name}")
|
||||||
if "FG_AUTH_BACKEND" not in app.config:
|
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")
|
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
|
||||||
|
|
||||||
|
|
||||||
@Hook("plugins.installed")
|
def create_app(test_config=None, cli=False):
|
||||||
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):
|
|
||||||
app = Flask("flaschengeist")
|
app = Flask("flaschengeist")
|
||||||
app.json_encoder = CustomJSONEncoder
|
app.json_encoder = CustomJSONEncoder
|
||||||
CORS(app)
|
CORS(app)
|
||||||
|
@ -103,7 +85,7 @@ def create_app(test_config=None):
|
||||||
configure_app(app, test_config)
|
configure_app(app, test_config)
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate.init_app(app, db, compare_type=True)
|
migrate.init_app(app, db, compare_type=True)
|
||||||
__load_plugins(app)
|
load_plugins(app)
|
||||||
|
|
||||||
@app.route("/", methods=["GET"])
|
@app.route("/", methods=["GET"])
|
||||||
def __get_state():
|
def __get_state():
|
||||||
|
|
|
@ -24,9 +24,16 @@ migrate = Migrate()
|
||||||
def configure_alembic(config):
|
def configure_alembic(config):
|
||||||
"""Alembic configuration hook
|
"""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
|
# 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:
|
if len(migrations) > 0:
|
||||||
# Get configured paths
|
# Get configured paths
|
||||||
paths = config.get_main_option("version_locations")
|
paths = config.get_main_option("version_locations")
|
||||||
|
|
|
@ -5,8 +5,8 @@
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from importlib_metadata import Distribution, EntryPoint
|
from importlib_metadata import Distribution, EntryPoint
|
||||||
from werkzeug.datastructures import FileStorage
|
|
||||||
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
from werkzeug.exceptions import MethodNotAllowed, NotFound
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
|
||||||
from flaschengeist.models.user import _Avatar, User
|
from flaschengeist.models.user import _Avatar, User
|
||||||
from flaschengeist.utils.hook import HookBefore, HookAfter
|
from flaschengeist.utils.hook import HookBefore, HookAfter
|
||||||
|
@ -121,6 +121,17 @@ class Plugin:
|
||||||
"""
|
"""
|
||||||
pass
|
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):
|
def get_setting(self, name: str, **kwargs):
|
||||||
"""Get plugin setting from database
|
"""Get plugin setting from database
|
||||||
|
|
||||||
|
@ -173,6 +184,11 @@ class Plugin:
|
||||||
|
|
||||||
|
|
||||||
class AuthPlugin(Plugin):
|
class AuthPlugin(Plugin):
|
||||||
|
"""Base class for all authentification plugins
|
||||||
|
|
||||||
|
See also `Plugin`
|
||||||
|
"""
|
||||||
|
|
||||||
def login(self, user, pw):
|
def login(self, user, pw):
|
||||||
"""Login routine, MUST BE IMPLEMENTED!
|
"""Login routine, MUST BE IMPLEMENTED!
|
||||||
|
|
||||||
|
@ -235,7 +251,7 @@ class AuthPlugin(Plugin):
|
||||||
"""
|
"""
|
||||||
raise MethodNotAllowed
|
raise MethodNotAllowed
|
||||||
|
|
||||||
def get_avatar(self, user: User) -> _Avatar:
|
def get_avatar(self, user):
|
||||||
"""Retrieve avatar for given user (if supported by auth backend)
|
"""Retrieve avatar for given user (if supported by auth backend)
|
||||||
|
|
||||||
Default behavior is to use native Image objects,
|
Default behavior is to use native Image objects,
|
||||||
|
@ -267,7 +283,7 @@ class AuthPlugin(Plugin):
|
||||||
|
|
||||||
user.avatar_ = imageController.upload_image(file)
|
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)
|
"""Delete the avatar for given user (if supported by auth backend)
|
||||||
|
|
||||||
Default behavior is to use the imageController and native Image objects.
|
Default behavior is to use the imageController and native Image objects.
|
||||||
|
|
|
@ -13,8 +13,8 @@ from flaschengeist.controller import sessionController, userController
|
||||||
|
|
||||||
|
|
||||||
class AuthRoutePlugin(Plugin):
|
class AuthRoutePlugin(Plugin):
|
||||||
name = "auth"
|
id = "dev.flaschengeist.auth"
|
||||||
blueprint = Blueprint(name, __name__)
|
blueprint = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"])
|
@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"])
|
||||||
|
|
|
@ -18,7 +18,7 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated
|
||||||
|
|
||||||
class AuthLDAP(AuthPlugin):
|
class AuthLDAP(AuthPlugin):
|
||||||
def __init__(self, entry_point, config):
|
def __init__(self, entry_point, config):
|
||||||
super().__init__(entry_point)
|
super().__init__(entry_point, config)
|
||||||
app.config.update(
|
app.config.update(
|
||||||
LDAP_SERVER=config.get("host", "localhost"),
|
LDAP_SERVER=config.get("host", "localhost"),
|
||||||
LDAP_PORT=config.get("port", 389),
|
LDAP_PORT=config.get("port", 389),
|
||||||
|
|
|
@ -14,6 +14,8 @@ from flaschengeist import logger
|
||||||
|
|
||||||
|
|
||||||
class AuthPlain(AuthPlugin):
|
class AuthPlain(AuthPlugin):
|
||||||
|
id = "auth_plain"
|
||||||
|
|
||||||
def install(self):
|
def install(self):
|
||||||
plugins_installed(self.post_install)
|
plugins_installed(self.post_install)
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ from . import Plugin
|
||||||
|
|
||||||
class MailMessagePlugin(Plugin):
|
class MailMessagePlugin(Plugin):
|
||||||
def __init__(self, entry_point, config):
|
def __init__(self, entry_point, config):
|
||||||
super().__init__(entry_point)
|
super().__init__(entry_point, config)
|
||||||
self.server = config["SERVER"]
|
self.server = config["SERVER"]
|
||||||
self.port = config["PORT"]
|
self.port = config["PORT"]
|
||||||
self.user = config["USER"]
|
self.user = config["USER"]
|
||||||
|
|
|
@ -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
|
Loading…
Reference in New Issue