Compare commits

...

4 Commits

Author SHA1 Message Date
Ferdinand Thiessen a289664f10 The enabled state of plugins is now loaded from database rather than config file
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 19:58:02 +02:00
Ferdinand Thiessen e41be21c47 Restructure models and database import paths
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 19:53:58 +02:00
Ferdinand Thiessen 7f8aa80b0e Update dependencies and increase python version to 3.10
Drop future imports, not needed with python 3.10

Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-08-18 19:45:54 +02:00
Ferdinand Thiessen dc2b949225 [cli] Fix exporting of plugin interfaces
Signed-off-by: Ferdinand Thiessen <rpm@fthiessen.de>
2022-07-31 19:28:11 +02:00
30 changed files with 335 additions and 261 deletions

View File

@ -4,14 +4,14 @@ 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
from importlib_metadata import entry_points from importlib.metadata import entry_points
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.models import Plugin
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
from flaschengeist.plugins import AuthPlugin from flaschengeist.config import configure_app
from flaschengeist.config import config, configure_app
class CustomJSONEncoder(JSONEncoder): class CustomJSONEncoder(JSONEncoder):
@ -39,39 +39,30 @@ class CustomJSONEncoder(JSONEncoder):
def load_plugins(app: Flask): def load_plugins(app: Flask):
app.config["FG_PLUGINS"] = {} app.config["FG_PLUGINS"] = {}
for entry_point in entry_points(group="flaschengeist.plugins"): enabled_plugins = Plugin.query.filter(Plugin.enabled == True).all()
logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})") all_plugins = entry_points(group="flaschengeist.plugins")
if entry_point.name == config["FLASCHENGEIST"]["auth"] or ( for plugin in enabled_plugins:
entry_point.name in config and config[entry_point.name].get("enabled", False) logger.debug(f"Searching for enabled plugin {plugin.name}")
): entry_point = all_plugins.select(name=plugin.name)
logger.debug(f"Load plugin {entry_point.name}") if not entry_point:
logger.error(
f"Plugin {plugin.name} was enabled, but could not be found.",
exc_info=True,
)
continue
try: try:
plugin = entry_point.load()(entry_point, config=config.get(entry_point.name, {})) loaded = entry_point.load()(entry_point)
if hasattr(plugin, "blueprint") and plugin.blueprint is not None: if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint) app.register_blueprint(plugin.blueprint)
except: except:
logger.error( logger.error(
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", f"Plugin {plugin.name} was enabled, but could not be loaded due to an error.",
exc_info=True, exc_info=True,
) )
continue continue
if isinstance(plugin, AuthPlugin): logger.info(f"Loaded plugin: {plugin.name}")
if entry_point.name != config["FLASCHENGEIST"]["auth"]: app.config["FG_PLUGINS"][plugin.name] = loaded
logger.debug(f"Unload not configured AuthPlugin {entry_point.name}")
del plugin
continue
else:
logger.info(f"Using authentication plugin: {entry_point.name}")
app.config["FG_AUTH_BACKEND"] = plugin
else:
logger.info(f"Using plugin: {entry_point.name}")
app.config["FG_PLUGINS"][entry_point.name] = plugin
else:
logger.debug(f"Skip disabled plugin {entry_point.name}")
if "FG_AUTH_BACKEND" not in app.config:
logger.fatal("No authentication plugin configured or authentication plugin not found")
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
def create_app(test_config=None, cli=False): def create_app(test_config=None, cli=False):

View File

@ -1,4 +1,5 @@
import click import click
from importlib.metadata import entry_points
@click.command() @click.command()
@ -8,14 +9,21 @@ import click
@click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) @click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True)
def export(namespace, output, no_core, plugin): def export(namespace, output, no_core, plugin):
from flaschengeist import logger, models from flaschengeist import logger, models
from flaschengeist.app import get_plugins
from .InterfaceGenerator import InterfaceGenerator from .InterfaceGenerator import InterfaceGenerator
gen = InterfaceGenerator(namespace, output, logger) gen = InterfaceGenerator(namespace, output, logger)
if not no_core: if not no_core:
gen.run(models) gen.run(models)
if plugin: if plugin:
for plugin_class in get_plugins(): for entry_point in entry_points(group="flaschengeist.plugins"):
if (len(plugin) == 0 or plugin_class.id in plugin) and plugin_class.models is not None: if len(plugin) == 0 or entry_point.name in plugin:
gen.run(plugin_class.models) try:
plugin = entry_point.load()
gen.run(plugin.models)
except:
logger.error(
f"Plugin {entry_point.name} could not be loaded due to an error.",
exc_info=True,
)
continue
gen.write() gen.write()

View File

@ -2,11 +2,11 @@ import click
from click.decorators import pass_context from click.decorators import pass_context
from flask import current_app from flask import current_app
from flask.cli import with_appcontext from flask.cli import with_appcontext
from importlib_metadata import EntryPoint, entry_points from importlib.metadata import EntryPoint, entry_points
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.models.user import Permission from flaschengeist.models import Permission
@click.group() @click.group()
@ -97,7 +97,9 @@ def ls(enabled, no_header):
return p.version return p.version
plugins = entry_points(group="flaschengeist.plugins") plugins = entry_points(group="flaschengeist.plugins")
enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [config["FLASCHENGEIST"]["auth"]] enabled_plugins = [key for key, value in config.items() if "enabled" in value and value["enabled"]] + [
config["FLASCHENGEIST"]["auth"]
]
loaded_plugins = current_app.config["FG_PLUGINS"].keys() loaded_plugins = current_app.config["FG_PLUGINS"].keys()
if not no_header: if not no_header:

View File

@ -77,17 +77,6 @@ def configure_app(app, test_config=None):
configure_logger() configure_logger()
# Always enable this builtin plugins!
update_dict(
config,
{
"auth": {"enabled": True},
"roles": {"enabled": True},
"users": {"enabled": True},
"scheduler": {"enabled": True},
},
)
if "secret_key" not in config["FLASCHENGEIST"]: if "secret_key" not in config["FLASCHENGEIST"]:
logger.critical("No secret key was configured, please configure one for production systems!") logger.critical("No secret key was configured, please configure one for production systems!")
raise RuntimeError("No secret key was configured") raise RuntimeError("No secret key was configured")

View File

@ -1,15 +1,14 @@
from datetime import date from datetime import date
from flask import send_file
from pathlib import Path from pathlib import Path
from flask import send_file
from PIL import Image as PImage from PIL import Image as PImage
from werkzeug.exceptions import NotFound, UnprocessableEntity
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import NotFound, UnprocessableEntity
from flaschengeist.models.image import Image from ..models import Image
from flaschengeist.database import db from ..database import db
from flaschengeist.config import config from ..config import config
def check_mimetype(mime: str): def check_mimetype(mime: str):

View File

@ -1,5 +1,5 @@
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
from flaschengeist.models.user import User, Role from flaschengeist.models import User, Role
class Message: class Message:

View File

@ -4,9 +4,16 @@ Used by plugins for setting and notification functionality.
""" """
import sqlalchemy import sqlalchemy
from typing import Union
from flask import current_app
from werkzeug.exceptions import NotFound
from importlib.metadata import entry_points
from .. import logger
from ..database import db from ..database import db
from ..models.setting import _PluginSetting from ..utils import Hook
from ..models.notification import Notification from ..models import Plugin, PluginSetting, Notification
def get_setting(plugin_id: str, name: str, **kwargs): def get_setting(plugin_id: str, name: str, **kwargs):
@ -23,7 +30,7 @@ def get_setting(plugin_id: str, name: str, **kwargs):
""" """
try: try:
setting = ( setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == plugin_id).filter(_PluginSetting.name == name).one() PluginSetting.query.filter(PluginSetting.plugin == plugin_id).filter(PluginSetting.name == name).one()
) )
return setting.value return setting.value
except sqlalchemy.orm.exc.NoResultFound: except sqlalchemy.orm.exc.NoResultFound:
@ -42,8 +49,8 @@ def set_setting(plugin_id: str, name: str, value):
value: Value to be stored value: Value to be stored
""" """
setting = ( setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == plugin_id) PluginSetting.query.filter(PluginSetting.plugin == plugin_id)
.filter(_PluginSetting.name == name) .filter(PluginSetting.name == name)
.one_or_none() .one_or_none()
) )
if setting is not None: if setting is not None:
@ -52,7 +59,7 @@ def set_setting(plugin_id: str, name: str, value):
else: else:
setting.value = value setting.value = value
else: else:
db.session.add(_PluginSetting(plugin=plugin_id, name=name, value=value)) db.session.add(PluginSetting(plugin=plugin_id, name=name, value=value))
db.session.commit() db.session.commit()
@ -74,3 +81,64 @@ def notify(plugin_id: str, user, text: str, data=None):
db.session.add(n) db.session.add(n)
db.session.commit() db.session.commit()
return n.id return n.id
@Hook("plugins.installed")
def install_plugin(plugin_name: str):
logger.debug(f"Installing plugin {plugin_name}")
entry_point = entry_points(group="flaschengeist.plugins", name=plugin_name)
if not entry_point:
raise NotFound
plugin = entry_point[0].load()(entry_point[0])
entity = Plugin(name=plugin.name, version=plugin.version)
db.session.add(entity)
db.session.commit()
return entity
@Hook("plugin.uninstalled")
def uninstall_plugin(plugin_id: Union[str, int]):
plugin = disable_plugin(plugin_id)
logger.debug(f"Uninstall plugin {plugin.name}")
entity = current_app.config["FG_PLUGINS"][plugin.name]
entity.uninstall()
del current_app.config["FG_PLUGINS"][plugin.name]
db.session.delete(plugin)
db.session.commit()
@Hook("plugins.enabled")
def enable_plugin(plugin_id: Union[str, int]):
logger.debug(f"Enabling plugin {plugin_id}")
plugin: Plugin = Plugin.query
if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
if plugin is None:
logger.debug("Plugin not installed, trying to install")
plugin = install_plugin(plugin_id)
else:
plugin = plugin.get(plugin_id)
if plugin is None:
raise NotFound
plugin.enabled = True
db.session.commit()
return plugin
@Hook("plugins.disabled")
def disable_plugin(plugin_id: Union[str, int]):
logger.debug(f"Disabling plugin {plugin_id}")
plugin: Plugin = Plugin.query
if isinstance(plugin_id, str):
plugin = plugin.filter(Plugin.name == plugin_id).one_or_none()
else:
plugin = plugin.get(plugin_id)
if plugin is None:
raise NotFound
plugin.enabled = False
db.session.commit()
return plugin

View File

@ -2,10 +2,10 @@ from typing import Union
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import BadRequest, Conflict, NotFound from werkzeug.exceptions import BadRequest, Conflict, NotFound
from flaschengeist import logger from .. import logger
from flaschengeist.models.user import Role, Permission from ..models import Role, Permission
from flaschengeist.database import db, case_sensitive from ..database import db, case_sensitive
from flaschengeist.utils.hook import Hook from ..utils.hook import Hook
def get_all(): def get_all():

View File

@ -1,9 +1,12 @@
import secrets import secrets
from flaschengeist.models.session import Session
from flaschengeist.database import db
from flaschengeist import logger
from werkzeug.exceptions import Forbidden, Unauthorized
from datetime import datetime, timezone from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized
from .. import logger
from ..models import Session
from ..database import db
lifetime = 1800 lifetime = 1800

View File

@ -1,5 +1,6 @@
import secrets
import re import re
import secrets
from io import BytesIO from io import BytesIO
from sqlalchemy import exc from sqlalchemy import exc
from flask import current_app from flask import current_app
@ -7,15 +8,15 @@ from datetime import datetime, timedelta, timezone
from flask.helpers import send_file from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden from werkzeug.exceptions import NotFound, BadRequest, Forbidden
from flaschengeist import logger from .. import logger
from flaschengeist.config import config from ..config import config
from flaschengeist.database import db from ..database import db
from flaschengeist.models.notification import Notification from ..models import Notification, User, Role
from flaschengeist.utils.hook import Hook from ..models.user import _PasswordReset
from flaschengeist.utils.datetime import from_iso_format from ..utils.hook import Hook
from flaschengeist.utils.foreign_keys import merge_references from ..utils.datetime import from_iso_format
from flaschengeist.models.user import User, Role, _PasswordReset from ..utils.foreign_keys import merge_references
from flaschengeist.controller import imageController, messageController, sessionController from ..controller import imageController, messageController, sessionController
def __active_users(): def __active_users():

View File

@ -1,7 +1,7 @@
import os import os
from flask_migrate import Migrate, Config from flask_migrate import Migrate, Config
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from importlib_metadata import EntryPoint from importlib.metadata import EntryPoint, entry_points, distribution
from sqlalchemy import MetaData from sqlalchemy import MetaData
from flaschengeist import logger from flaschengeist import logger
@ -30,8 +30,6 @@ def configure_alembic(config: Config):
This includes even disabled plugins, as simply disabling a plugin without This includes even disabled plugins, as simply disabling a plugin without
uninstall can break the alembic version management. uninstall can break the alembic version management.
""" """
from importlib_metadata import entry_points, distribution
# Set main script location # Set main script location
config.set_main_option( config.set_main_option(
"script_location", str(distribution("flaschengeist").locate_file("") / "flaschengeist" / "alembic") "script_location", str(distribution("flaschengeist").locate_file("") / "flaschengeist" / "alembic")

View File

@ -0,0 +1,95 @@
import sys
import datetime
from sqlalchemy import BigInteger, util
from sqlalchemy.dialects import mysql, sqlite
from sqlalchemy.types import DateTime, TypeDecorator
class ModelSerializeMixin:
"""Mixin class used for models to serialize them automatically
Ignores private and protected members as well as members marked as not to publish (name ends with _)
"""
def __is_optional(self, param):
if sys.version_info < (3, 8):
return False
import typing
hint = typing.get_type_hints(self.__class__)[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
def serialize(self):
"""Serialize class to dict
Returns:
Dict of all not private or protected annotated member variables.
"""
d = {
param: getattr(self, param)
for param in self.__class__.__annotations__
if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param)
}
if len(d) == 1:
key, value = d.popitem()
return value
return d
def __str__(self) -> str:
return self.serialize().__str__()
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite")
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:
- Never silently take naive :class:`datetime.datetime`, instead it
always raise :exc:`ValueError` unless time zone aware value.
- :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo`
is always converted to UTC.
- Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`,
it never return naive :class:`datetime.datetime`, but time zone
aware value, even with SQLite or MySQL.
"""
cache_ok = True
impl = DateTime(timezone=True)
@staticmethod
def current_utc():
return datetime.datetime.now(tz=datetime.timezone.utc)
def process_bind_param(self, value, dialect):
if value is not None:
if not isinstance(value, datetime.datetime):
raise TypeError("expected datetime.datetime, not " + repr(value))
elif value.tzinfo is None:
raise ValueError("naive datetime is disallowed")
return value.astimezone(datetime.timezone.utc)
def process_result_value(self, value, dialect):
if value is not None:
if value.tzinfo is not None:
value = value.astimezone(datetime.timezone.utc)
value = value.replace(tzinfo=datetime.timezone.utc)
return value
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)

View File

@ -1,95 +1,5 @@
import sys from .session import *
import datetime from .user import *
from .plugin import *
from sqlalchemy import BigInteger, util from .notification import *
from sqlalchemy.dialects import mysql, sqlite from .image import *
from sqlalchemy.types import DateTime, TypeDecorator
class ModelSerializeMixin:
"""Mixin class used for models to serialize them automatically
Ignores private and protected members as well as members marked as not to publish (name ends with _)
"""
def __is_optional(self, param):
if sys.version_info < (3, 8):
return False
import typing
hint = typing.get_type_hints(self.__class__)[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
def serialize(self):
"""Serialize class to dict
Returns:
Dict of all not private or protected annotated member variables.
"""
d = {
param: getattr(self, param)
for param in self.__class__.__annotations__
if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param)
}
if len(d) == 1:
key, value = d.popitem()
return value
return d
def __str__(self) -> str:
return self.serialize().__str__()
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER(), "sqlite")
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:
- Never silently take naive :class:`datetime.datetime`, instead it
always raise :exc:`ValueError` unless time zone aware value.
- :class:`datetime.datetime` value's :attr:`datetime.datetime.tzinfo`
is always converted to UTC.
- Unlike SQLAlchemy's built-in :class:`sqlalchemy.types.DateTime`,
it never return naive :class:`datetime.datetime`, but time zone
aware value, even with SQLite or MySQL.
"""
cache_ok = True
impl = DateTime(timezone=True)
@staticmethod
def current_utc():
return datetime.datetime.now(tz=datetime.timezone.utc)
def process_bind_param(self, value, dialect):
if value is not None:
if not isinstance(value, datetime.datetime):
raise TypeError("expected datetime.datetime, not " + repr(value))
elif value.tzinfo is None:
raise ValueError("naive datetime is disallowed")
return value.astimezone(datetime.timezone.utc)
def process_result_value(self, value, dialect):
if value is not None:
if value.tzinfo is not None:
value = value.astimezone(datetime.timezone.utc)
value = value.replace(tzinfo=datetime.timezone.utc)
return value
# https://alembic.sqlalchemy.org/en/latest/autogenerate.html?highlight=custom%20column#affecting-the-rendering-of-types-themselves
def __repr__(self) -> str:
return util.generic_repr(self)

View File

@ -1,10 +1,8 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from sqlalchemy import event from sqlalchemy import event
from pathlib import Path from pathlib import Path
from . import ModelSerializeMixin, Serial
from ..database import db from ..database import db
from ..database.types import ModelSerializeMixin, Serial
class Image(db.Model, ModelSerializeMixin): class Image(db.Model, ModelSerializeMixin):

View File

@ -1,19 +1,24 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from . import Serial, UtcDateTime, ModelSerializeMixin
from ..database import db from ..database import db
from .user import User from ..database.types import Serial, UtcDateTime, ModelSerializeMixin
class Notification(db.Model, ModelSerializeMixin): class Notification(db.Model, ModelSerializeMixin):
__tablename__ = "notification" __tablename__ = "notification"
id: int = db.Column("id", Serial, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
plugin: str = db.Column(db.String(127), nullable=False)
text: str = db.Column(db.Text) text: str = db.Column(db.Text)
data: Any = db.Column(db.PickleType(protocol=4)) data: Any = db.Column(db.PickleType(protocol=4))
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
user_id_: int = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False) user_id_: int = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False)
user_: User = db.relationship("User") plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"), nullable=False)
user_: "User" = db.relationship("User")
plugin_: "Plugin" = db.relationship(
"Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan")
)
@property
def plugin(self):
return self.plugin_.name

View File

@ -0,0 +1,24 @@
from typing import Any
from ..database import db
from ..database.types import Serial
class Plugin(db.Model):
__tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False)
version: str = db.Column(db.String(30), nullable=False)
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
settings_ = db.relationship("PluginSetting", cascade="all, delete")
permissions_ = db.relationship("Permission", cascade="all, delete")
class PluginSetting(db.Model):
__tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True)
plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
name: str = db.Column(db.String(127), nullable=False)
value: Any = db.Column(db.PickleType(protocol=4))

View File

@ -1,11 +1,9 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from . import ModelSerializeMixin, UtcDateTime, Serial
from .user import User
from flaschengeist.database import db
from secrets import compare_digest from secrets import compare_digest
from ..database import db
from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
from flaschengeist import logger from flaschengeist import logger
@ -28,7 +26,7 @@ class Session(db.Model, ModelSerializeMixin):
_id = db.Column("id", Serial, primary_key=True) _id = db.Column("id", Serial, primary_key=True)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id")) _user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"))
user_: User = db.relationship("User", back_populates="sessions_") user_: "User" = db.relationship("User", back_populates="sessions_")
@property @property
def userid(self): def userid(self):

View File

@ -1,13 +0,0 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from typing import Any
from . import Serial
from ..database import db
class _PluginSetting(db.Model):
__tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True)
plugin: str = db.Column(db.String(127))
name: str = db.Column(db.String(127), nullable=False)
value: Any = db.Column(db.PickleType(protocol=4))

View File

@ -1,14 +1,9 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from flask import url_for
from typing import Optional from typing import Optional
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db from ..database import db
from . import ModelSerializeMixin, UtcDateTime, Serial from ..database.types import ModelSerializeMixin, UtcDateTime, Serial
from .image import Image
association_table = db.Table( association_table = db.Table(
"user_x_role", "user_x_role",
@ -22,12 +17,12 @@ role_permission_association_table = db.Table(
db.Column("permission_id", Serial, db.ForeignKey("permission.id")), db.Column("permission_id", Serial, db.ForeignKey("permission.id")),
) )
class Permission(db.Model, ModelSerializeMixin): class Permission(db.Model, ModelSerializeMixin):
__tablename__ = "permission" __tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True) name: str = db.Column(db.String(30), unique=True)
_id = db.Column("id", Serial, primary_key=True) _id = db.Column("id", Serial, primary_key=True)
_plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
class Role(db.Model, ModelSerializeMixin): class Role(db.Model, ModelSerializeMixin):
@ -69,7 +64,7 @@ class User(db.Model, ModelSerializeMixin):
sessions_: list["Session"] = db.relationship( sessions_: list["Session"] = db.relationship(
"Session", back_populates="user_", cascade="all, delete, delete-orphan" "Session", back_populates="user_", cascade="all, delete, delete-orphan"
) )
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) avatar_: Optional["Image"] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan")
# Private stuff for internal use # Private stuff for internal use

View File

@ -5,11 +5,12 @@
""" """
from typing import Optional from typing import Optional
from importlib_metadata import Distribution, EntryPoint from importlib.metadata import Distribution, EntryPoint
from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from flaschengeist.models.user import _Avatar, User from flaschengeist.models import User
from flaschengeist.models.user import _Avatar
from flaschengeist.utils.hook import HookBefore, HookAfter from flaschengeist.utils.hook import HookBefore, HookAfter
__all__ = [ __all__ = [
@ -19,7 +20,7 @@ __all__ = [
"before_role_updated", "before_role_updated",
"before_update_user", "before_update_user",
"after_role_updated", "after_role_updated",
"Plugin", "BasePlugin",
"AuthPlugin", "AuthPlugin",
] ]
@ -70,7 +71,7 @@ Passed args:
""" """
class Plugin: class BasePlugin:
"""Base class for all Plugins """Base class for all Plugins
All plugins must derived from this class. All plugins must derived from this class.
@ -114,10 +115,10 @@ class Plugin:
``` ```
""" """
def __init__(self, entry_point: EntryPoint, config=None): def __init__(self, entry_point: EntryPoint):
"""Constructor called by create_app """Constructor called by create_app
Args: Args:
config: Dict configuration containing the plugin section entry_point: EntryPoint from which this plugin was loaded
""" """
self.version = entry_point.dist.version self.version = entry_point.dist.version
self.name = entry_point.name self.name = entry_point.name
@ -126,6 +127,8 @@ class Plugin:
def install(self): def install(self):
"""Installation routine """Installation routine
Also called when updating the plugin, compare `version` and `installed_version`.
Is always called with Flask application context, Is always called with Flask application context,
it is called after the plugin permissions are installed. it is called after the plugin permissions are installed.
""" """
@ -142,6 +145,14 @@ class Plugin:
""" """
pass pass
@property
def installed_version(self):
"""Installed version of the plugin"""
from ..controller import pluginController
self.__installed_version = pluginController.get_installed_version(self.name)
return self.__installed_version
def get_setting(self, name: str, **kwargs): def get_setting(self, name: str, **kwargs):
"""Get plugin setting from database """Get plugin setting from database
@ -193,10 +204,10 @@ class Plugin:
return {"version": self.version, "permissions": self.permissions} return {"version": self.version, "permissions": self.permissions}
class AuthPlugin(Plugin): class AuthPlugin(BasePlugin):
"""Base class for all authentification plugins """Base class for all authentification plugins
See also `Plugin` See also `BasePlugin`
""" """
def login(self, user, pw): def login(self, user, pw):

View File

@ -6,13 +6,13 @@ from flask import Blueprint, request, jsonify
from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized from werkzeug.exceptions import Forbidden, BadRequest, Unauthorized
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.plugins import Plugin from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.HTTP import no_content, created from flaschengeist.utils.HTTP import no_content, created
from flaschengeist.utils.decorators import login_required from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import sessionController, userController from flaschengeist.controller import sessionController, userController
class AuthRoutePlugin(Plugin): class AuthRoutePlugin(BasePlugin):
blueprint = Blueprint("auth", __name__) blueprint = Blueprint("auth", __name__)

View File

@ -12,7 +12,8 @@ from werkzeug.datastructures import FileStorage
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.models.user import User, Role, _Avatar from flaschengeist.models import User, Role
from flaschengeist.models.user import _Avatar
from flaschengeist.plugins import AuthPlugin, before_role_updated from flaschengeist.plugins import AuthPlugin, before_role_updated

View File

@ -8,7 +8,7 @@ import hashlib
import binascii import binascii
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from flaschengeist.plugins import AuthPlugin, plugins_installed from flaschengeist.plugins import AuthPlugin, plugins_installed
from flaschengeist.models.user import User, Role, Permission from flaschengeist.models import User, Role, Permission
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist import logger from flaschengeist import logger

View File

@ -1,5 +1,3 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property

View File

@ -3,15 +3,14 @@ from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.models.user import User from flaschengeist.models import User
from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.hook import HookAfter from flaschengeist.utils.hook import HookAfter
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.controller.messageController import Message from flaschengeist.controller.messageController import Message
from . import Plugin
class MailMessagePlugin(BasePlugin):
class MailMessagePlugin(Plugin):
def __init__(self, entry_point, config): def __init__(self, entry_point, config):
super().__init__(entry_point, config) super().__init__(entry_point, config)
self.server = config["SERVER"] self.server = config["SERVER"]

View File

@ -1,8 +1,6 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models import ModelSerializeMixin, Serial from flaschengeist.database.types import ModelSerializeMixin, Serial
from flaschengeist.models.image import Image from flaschengeist.models import Image
from typing import Optional from typing import Optional

View File

@ -5,17 +5,16 @@ Provides routes used to configure roles and permissions of users / roles.
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from http.client import NO_CONTENT
from flaschengeist.plugins import Plugin from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import roleController from flaschengeist.controller import roleController
from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.decorators import login_required
from . import permissions from . import permissions
class RolesPlugin(Plugin): class RolesPlugin(BasePlugin):
blueprint = Blueprint("roles", __name__) blueprint = Blueprint("roles", __name__)
permissions = permissions.permissions permissions = permissions.permissions

View File

@ -2,10 +2,9 @@ from flask import Blueprint
from datetime import datetime, timedelta from datetime import datetime, timedelta
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.plugins import BasePlugin
from flaschengeist.utils.HTTP import no_content from flaschengeist.utils.HTTP import no_content
from . import Plugin
class __Task: class __Task:
def __init__(self, function, **kwags): def __init__(self, function, **kwags):
@ -38,7 +37,7 @@ def scheduled(id: str, replace=False, **kwargs):
return real_decorator return real_decorator
class SchedulerPlugin(Plugin): class SchedulerPlugin(BasePlugin):
def __init__(self, entry_point, config=None): def __init__(self, entry_point, config=None):
super().__init__(entry_point, config) super().__init__(entry_point, config)
self.blueprint = Blueprint(self.name, __name__) self.blueprint = Blueprint(self.name, __name__)

View File

@ -2,14 +2,14 @@
Provides routes used to manage users Provides routes used to manage users
""" """
from http.client import NO_CONTENT, CREATED from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response from flask import Blueprint, request, jsonify, make_response
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed, NotFound from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions from . import permissions
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.plugins import Plugin from flaschengeist.plugins import BasePlugin
from flaschengeist.models.user import User from flaschengeist.models.user import User
from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController from flaschengeist.controller import userController
@ -17,7 +17,7 @@ from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.datetime import from_iso_format
class UsersPlugin(Plugin): class UsersPlugin(BasePlugin):
blueprint = Blueprint("users", __name__) blueprint = Blueprint("users", __name__)
permissions = permissions.permissions permissions = permissions.permissions

View File

@ -19,19 +19,17 @@ classifiers =
[options] [options]
include_package_data = True include_package_data = True
python_requires = >=3.9 python_requires = >=3.10
packages = find: packages = find:
install_requires = install_requires =
Flask>=2.0 Flask==2.0.3
Pillow>=8.4.0 Pillow>=9.0
flask_cors flask_cors
flask_migrate>=3.1.0 flask_migrate>=3.1.0
flask_sqlalchemy>=2.5 flask_sqlalchemy>=2.5.1
# Importlib requirement can be dropped when python requirement is >= 3.10 sqlalchemy>=1.4.39
importlib_metadata>=4.3
sqlalchemy>=1.4.26
toml toml
werkzeug >= 2.0 werkzeug==2.0.3
[options.extras_require] [options.extras_require]
argon = argon2-cffi argon = argon2-cffi