Compare commits

..

12 Commits

Author SHA1 Message Date
Ferdinand Thiessen 5ff906086b fix(app): Skip plugins with not satisfied dependencies.
continuous-integration/woodpecker the build failed Details
2021-12-23 03:07:39 +01:00
Ferdinand Thiessen 04dcf39dfc fix(plugins): Fix plugin version for plugin list API endpoint 2021-12-23 03:06:54 +01:00
Ferdinand Thiessen 8e18a11fc8 feat(cli): Added CLI command for handling plugins
continuous-integration/woodpecker the build failed Details
* Install / Uninstall plugins
* List plugins
2021-12-23 02:50:04 +01:00
Ferdinand Thiessen 878a61f1c2 feat(plugins): Identify plugins by id, migrations must be provided at defined location, add utils for plugin functions 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen c4bf33d1c7 fix(docs): Various documentation fixed and improvments 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen a1a20b0d65 docs(migrations): Some documentation ++ 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen c647c7c8f8 feat(docs): Add documentation on how to install tables 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen a9416e5ca3 fix(db): Remove print statement for debugging 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen fc58f8c952 feat(db): Add initial migrations for core Flaschengeist + balance and pricelist plugins 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen 92b231224d feat(db): Add migrations support to plugins 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen e4366b8e9e fix(db): Add __repr__ to custom column types, same as done by SQLAlchemy 2021-12-23 02:50:04 +01:00
Ferdinand Thiessen 095159af71 feat(db): Add database migration support, implements #19
Migrations allow us to keep track of database changes and upgrading databases if needed.
2021-12-23 02:50:04 +01:00
14 changed files with 130 additions and 216 deletions

View File

@ -3,4 +3,4 @@ pipeline:
image: python:slim image: python:slim
commands: commands:
- pip install black - pip install black
- black --check --line-length 120 --target-version=py39 . - black --check --line-length 120 --target-version=py37 .

View File

@ -17,3 +17,5 @@ matrix:
PYTHON: PYTHON:
- 3.10 - 3.10
- 3.9 - 3.9
- 3.8
- 3.7

View File

@ -3,33 +3,27 @@
This is the backend of the Flaschengeist. This is the backend of the Flaschengeist.
## Installation # Installation
### Requirements ## Main package
- `mysql` or `mariadb` ### System dependencies
- maybe `libmariadb` development files[1] - **python 3.7+**
- python 3.9+ - Database (MySQL / mariadb by default)
- pip 21.0+
*[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on By default Flaschengeist uses mysql as database backend,
Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the if you are on Windows Flaschengeist uses `PyMySQL`, which does not require any other system packages.
development files for `libmariadb` to be present on your system.*
But on Linux / Mac / *nix the faster `mysqlclient` is used,
if it is not already installed, installing from PyPi requires the
development files for `libmariadb` to be present on your system.
### Install python files ### Install python files
It is recommended to upgrade pip to the latest version before installing: pip3 install --user .
python -m pip install --upgrade pip
Default installation with *mariadb*/*mysql* support:
pip3 install --user ".[mysql]"
or with ldap support or with ldap support
pip3 install --user ".[ldap]" pip3 install --user ".[ldap]"
or if you want to also run the tests: or if you want to also run the tests:
pip3 install --user ".[ldap,tests]" pip3 install --user ".[ldap,test]"
You will also need a MySQL driver, by default one of this is installed: You will also need a MySQL driver, by default one of this is installed:
- `mysqlclient` (non Windows) - `mysqlclient` (non Windows)
@ -81,12 +75,6 @@ So you have to configure one of the following options to call flaschengeists CRO
- Cons: Uses one of the webserver threads while executing - Cons: Uses one of the webserver threads while executing
### Run ### Run
Flaschengeist provides a CLI, based on the flask CLI, respectivly called `flaschengeist`.
⚠️ When using the CLI for running Flaschengeist, please note that logging will happen as configured,
with the difference of the main logger will be forced to output to `stderr` and the logging level
of the CLI will override the logging level you have configured for the main logger.
$ flaschengeist run $ flaschengeist run
or with debug messages: or with debug messages:

View File

@ -1,9 +1,10 @@
"""Flaschengeist""" """Flaschengeist"""
import logging import logging
from importlib.metadata import version import pkg_resources
from werkzeug.local import LocalProxy
__version__ = version("flaschengeist") __version__ = pkg_resources.get_distribution("flaschengeist").version
__pdoc__ = {} __pdoc__ = {}
logger = logging.getLogger(__name__) logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__))
__pdoc__["logger"] = "Flaschengeist's logger instance (`werkzeug.local.LocalProxy`)" __pdoc__["logger"] = "Flaschengeist's logger instance (`werkzeug.local.LocalProxy`)"

View File

@ -7,12 +7,12 @@ from flask.json import JSONEncoder, jsonify
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.utils.hook import Hook
from flaschengeist.plugins import AuthPlugin, Plugin
from flaschengeist.utils.plugin import get_plugins from flaschengeist.utils.plugin import get_plugins
from flaschengeist.controller import roleController
from flaschengeist.config import config, configure_app from . import logger
from .plugins import Plugin
from .config import config, configure_app
from .utils.hook import Hook
class CustomJSONEncoder(JSONEncoder): class CustomJSONEncoder(JSONEncoder):
@ -67,14 +67,14 @@ def load_plugins(app: Flask):
def create_app(test_config=None, cli=False): def create_app(test_config=None, cli=False):
app = Flask("flaschengeist") app = Flask(__name__)
app.json_encoder = CustomJSONEncoder app.json_encoder = CustomJSONEncoder
CORS(app) CORS(app)
with app.app_context(): with app.app_context():
from flaschengeist.database import db, migrate from flaschengeist.database import db, migrate
configure_app(app, test_config) configure_app(app, test_config, cli)
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)

View File

@ -1,14 +1,7 @@
from os import environ
import sys
import click import click
import logging
from flask.cli import FlaskGroup, with_appcontext from flask.cli import FlaskGroup, with_appcontext
from flaschengeist import logger
from flaschengeist.app import create_app from flaschengeist.app import create_app
from flaschengeist.config import configure_logger
LOGGING_MIN = 5 # TRACE (custom)
LOGGING_MAX = logging.ERROR
def get_version(ctx, param, value): def get_version(ctx, param, value):
@ -30,37 +23,19 @@ def get_version(ctx, param, value):
ctx.exit() ctx.exit()
def configure_logger(level):
"""Reconfigure main logger"""
global logger
# Handle TRACE -> meaning enable debug even for werkzeug
if level == 5:
level = 10
logging.getLogger("werkzeug").setLevel(level)
logger.setLevel(level)
environ["FG_LOGGING"] = logging.getLevelName(level)
for h in logger.handlers:
if isinstance(h, logging.StreamHandler) and h.name == "wsgi":
h.setLevel(level)
h.setStream(sys.stderr)
@with_appcontext @with_appcontext
def verbosity(ctx, param, value): def verbosity(ctx, param, value):
"""Callback: Toggle verbosity between ERROR <-> TRACE""" """Toggle verbosity between WARNING <-> DEBUG"""
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
return return
configure_logger(LOGGING_MAX - max(LOGGING_MIN, min(value * 10, LOGGING_MAX - LOGGING_MIN))) configure_logger(cli=30 - max(0, min(value * 10, 20)))
@click.group( @click.group(
cls=FlaskGroup, cls=FlaskGroup,
add_version_option=False, add_version_option=False,
add_default_commands=False, add_default_commands=False,
create_app=create_app, create_app=lambda: create_app(cli=30),
) )
@click.option( @click.option(
"--version", "--version",
@ -84,15 +59,12 @@ def cli():
def main(*args, **kwargs): def main(*args, **kwargs):
# from .plugin_cmd import plugin from .plugin_cmd import plugin
from .export_cmd import export from .export_cmd import export
from .docs_cmd import docs from .docs_cmd import docs
from .run_cmd import run from .run_cmd import run
# Override logging level cli.add_command(plugin)
environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX))
# cli.add_command(plugin)
cli.add_command(export) cli.add_command(export)
cli.add_command(docs) cli.add_command(docs)
cli.add_command(run) cli.add_command(run)

View File

@ -28,11 +28,13 @@ class PrefixMiddleware(object):
@click.pass_context @click.pass_context
def run(ctx, host, port, debug): def run(ctx, host, port, debug):
"""Run Flaschengeist using a development server.""" """Run Flaschengeist using a development server."""
from flaschengeist.config import config from flaschengeist.config import config, configure_logger
# re configure logger, as we are no logger in CLI mode
configure_logger()
current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", "")) current_app.wsgi_app = PrefixMiddleware(current_app.wsgi_app, prefix=config["FLASCHENGEIST"].get("root", ""))
if debug: if debug:
environ["FLASK_DEBUG"] = "1" environ["FLASK_DEBUG"] = "1"
environ["FLASK_ENV"] = "development" environ["FLASK_ENV"] = "development"
ctx.invoke(run_command, reload=True, host=host, port=port, debugger=debug) ctx.invoke(run_command, host=host, port=port, debugger=debug)

View File

@ -1,13 +1,13 @@
import os import os
import toml import toml
import logging.config
import collections.abc import collections.abc
from pathlib import Path from pathlib import Path
from logging.config import dictConfig
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from flaschengeist import logger from flaschengeist import logger
# Default config: # Default config:
config = {"DATABASE": {"engine": "mysql", "port": 3306}} config = {"DATABASE": {"engine": "mysql", "port": 3306}}
@ -41,41 +41,36 @@ def read_configuration(test_config):
update_dict(config, test_config) update_dict(config, test_config)
def configure_logger(): def configure_logger(cli=False):
"""Configure the logger global config
force_console: Force a console handler
"""
def set_level(level):
# TRACE means even with werkzeug's request traces
if isinstance(level, str) and level.lower() == "trace":
level = "DEBUG"
logger_config["loggers"]["werkzeug"] = {"level": level}
logger_config["loggers"]["flaschengeist"] = {"level": level}
logger_config["handlers"]["wsgi"]["level"] = level
# Read default config # Read default config
logger_config = toml.load(Path(__file__).parent / "logging.toml") logger_config = toml.load(Path(__file__).parent / "logging.toml")
if "LOGGING" in config: if "LOGGING" in config:
# Override with user config # Override with user config
update_dict(logger_config, config.get("LOGGING")) update_dict(logger_config, config.get("LOGGING"))
# Check for shortcuts # Check for shortcuts
if "level" in config["LOGGING"]: if "level" in config["LOGGING"] or isinstance(cli, int):
set_level(config["LOGGING"]["level"]) level = cli if cli and isinstance(cli, int) else config["LOGGING"]["level"]
logger_config["loggers"]["flaschengeist"] = {"level": level}
# Override logging, used e.g. by CLI logger_config["handlers"]["console"]["level"] = level
if "FG_LOGGING" in os.environ: logger_config["handlers"]["file"]["level"] = level
set_level(os.environ.get("FG_LOGGING", "CRITICAL")) if cli is True or not config["LOGGING"].get("console", True):
logger_config["handlers"]["console"]["level"] = "CRITICAL"
dictConfig(logger_config) if not cli and isinstance(config["LOGGING"].get("file", False), str):
logger_config["root"]["handlers"].append("file")
logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"]
Path(config["LOGGING"]["file"]).parent.mkdir(parents=True, exist_ok=True)
else:
del logger_config["handlers"]["file"]
logging.config.dictConfig(logger_config)
def configure_app(app, test_config=None): def configure_app(app, test_config=None, cli=False):
global config global config
read_configuration(test_config) read_configuration(test_config)
configure_logger() configure_logger(cli)
# Always enable this builtin plugins! # Always enable this builtin plugins!
update_dict( update_dict(
@ -89,10 +84,10 @@ def configure_app(app, test_config=None):
) )
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.warning("No secret key was configured, please configure one for production systems!")
raise RuntimeError("No secret key was configured") app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29"
else:
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
if test_config is not None: if test_config is not None:
config["DATABASE"]["engine"] = "sqlite" config["DATABASE"]["engine"] = "sqlite"

View File

@ -1,76 +0,0 @@
"""Controller for Plugin logic
Used by plugins for setting and notification functionality.
"""
import sqlalchemy
from ..database import db
from ..models.setting import _PluginSetting
from ..models.notification import Notification
def get_setting(plugin_id: str, name: str, **kwargs):
"""Get plugin setting from database
Args:
plugin_id: ID of the plugin
name: string identifying the setting
default: Default value
Returns:
Value stored in database (native python)
Raises:
`KeyError` if no such setting exists in the database
"""
try:
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == plugin_id).filter(_PluginSetting.name == name).one()
)
return setting.value
except sqlalchemy.orm.exc.NoResultFound:
if "default" in kwargs:
return kwargs["default"]
else:
raise KeyError
def set_setting(plugin_id: str, name: str, value):
"""Save setting in database
Args:
plugin_id: ID of the plugin
name: String identifying the setting
value: Value to be stored
"""
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == plugin_id)
.filter(_PluginSetting.name == name)
.one_or_none()
)
if setting is not None:
if value is None:
db.session.delete(setting)
else:
setting.value = value
else:
db.session.add(_PluginSetting(plugin=plugin_id, name=name, value=value))
db.session.commit()
def notify(plugin_id: str, user, text: str, data=None):
"""Create a new notification for an user
Args:
plugin_id: ID of the plugin
user: `flaschengeist.models.user.User` to notify
text: Visibile notification text
data: Optional data passed to the notificaton
Returns:
ID of the created `flaschengeist.models.notification.Notification`
Hint: use the data for frontend actions.
"""
if not user.deleted:
n = Notification(text=text, data=data, plugin=plugin_id, user_=user)
db.session.add(n)
db.session.commit()
return n.id

View File

@ -12,6 +12,23 @@ root = "/api"
secret_key = "V3ryS3cr3t" secret_key = "V3ryS3cr3t"
# Domain used by frontend # Domain used by frontend
[scheduler]
# Possible values are: "passive_web" (default), "active_web" and "system"
# See documentation
# cron = "passive_web"
[LOGGING]
# You can override all settings from the logging.toml here
# E.g. override the formatters etc
#
# Logging level, possible: DEBUG INFO WARNING ERROR
level = "DEBUG"
# Logging to a file is simple, just add the path
# file = "/tmp/flaschengeist-debug.log"
file = false
# Uncomment to disable console logging
# console = false
[DATABASE] [DATABASE]
# engine = "mysql" (default) # engine = "mysql" (default)
host = "localhost" host = "localhost"
@ -19,22 +36,6 @@ user = "flaschengeist"
password = "flaschengeist" password = "flaschengeist"
database = "flaschengeist" database = "flaschengeist"
[LOGGING]
# You can override all settings from the logging.toml here
# Default: Logging to WSGI stream (commonly stderr)
# Logging level, possible: TRACE DEBUG INFO WARNING ERROR CRITICAL
# On TRACE level additionally every request will get logged
level = "DEBUG"
# If you want the logger to log to a file, you could use:
#[LOGGING.handlers.file]
# class = "logging.handlers.WatchedFileHandler"
# level = "WARNING"
# formatter = "extended"
# encoding = "utf8"
# filename = "flaschengeist.log"
[FILES] [FILES]
# Path for file / image uploads # Path for file / image uploads
data_path = "./data" data_path = "./data"
@ -48,11 +49,6 @@ allowed_mimetypes = [
"image/webp" "image/webp"
] ]
[scheduler]
# Possible values are: "passive_web" (default), "active_web" and "system"
# See documentation
# cron = "passive_web"
[auth_ldap] [auth_ldap]
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap # Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
# host = "localhost" # host = "localhost"

View File

@ -6,16 +6,22 @@ disable_existing_loggers = false
[formatters] [formatters]
[formatters.simple] [formatters.simple]
format = "[%(asctime)s] %(levelname)s - %(message)s" format = "%(asctime)s - %(levelname)s - %(message)s"
[formatters.extended] [formatters.extended]
format = "[%(asctime)s] %(levelname)s %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(message)s" format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s"
[handlers] [handlers]
[handlers.wsgi] [handlers.console]
stream = "ext://flask.logging.wsgi_errors_stream"
class = "logging.StreamHandler" class = "logging.StreamHandler"
formatter = "simple"
level = "DEBUG" level = "DEBUG"
formatter = "simple"
stream = "ext://sys.stderr"
[handlers.file]
class = "logging.handlers.WatchedFileHandler"
level = "WARNING"
formatter = "extended"
encoding = "utf8"
filename = "flaschengeist.log"
[loggers] [loggers]
[loggers.werkzeug] [loggers.werkzeug]
@ -23,4 +29,4 @@ disable_existing_loggers = false
[root] [root]
level = "WARNING" level = "WARNING"
handlers = ["wsgi"] handlers = ["console"]

View File

@ -24,6 +24,7 @@ For more information, please refer to
- `flaschengeist.utils.hook.HookAfter` - `flaschengeist.utils.hook.HookAfter`
""" """
import sqlalchemy
from werkzeug.exceptions import MethodNotAllowed, NotFound from werkzeug.exceptions import MethodNotAllowed, NotFound
from flaschengeist.utils.hook import HookBefore, HookAfter from flaschengeist.utils.hook import HookBefore, HookAfter
@ -166,9 +167,20 @@ class Plugin:
Raises: Raises:
`KeyError` if no such setting exists in the database `KeyError` if no such setting exists in the database
""" """
from ..controller import pluginController from flaschengeist.models.setting import _PluginSetting
return pluginController.get_setting(self.id) try:
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == self.name)
.filter(_PluginSetting.name == name)
.one()
)
return setting.value
except sqlalchemy.orm.exc.NoResultFound:
if "default" in kwargs:
return kwargs["default"]
else:
raise KeyError
def set_setting(self, name: str, value): def set_setting(self, name: str, value):
"""Save setting in database """Save setting in database
@ -177,9 +189,22 @@ class Plugin:
name: String identifying the setting name: String identifying the setting
value: Value to be stored value: Value to be stored
""" """
from ..controller import pluginController from flaschengeist.models.setting import _PluginSetting
from flaschengeist.database import db
return pluginController.set_setting(self.id, name, value) setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == self.name)
.filter(_PluginSetting.name == name)
.one_or_none()
)
if setting is not None:
if value is None:
db.session.delete(setting)
else:
setting.value = value
else:
db.session.add(_PluginSetting(plugin=self.name, name=name, value=value))
db.session.commit()
def notify(self, user, text: str, data=None): def notify(self, user, text: str, data=None):
"""Create a new notification for an user """Create a new notification for an user
@ -193,9 +218,14 @@ class Plugin:
Hint: use the data for frontend actions. Hint: use the data for frontend actions.
""" """
from ..controller import pluginController from flaschengeist.models.notification import Notification
from flaschengeist.database import db
return pluginController.notify(self.id, user, text, data) if not user.deleted:
n = Notification(text=text, data=data, plugin=self.id, user_=user)
db.session.add(n)
db.session.commit()
return n.id
def serialize(self): def serialize(self):
"""Serialize a plugin into a dict """Serialize a plugin into a dict
@ -301,9 +331,7 @@ class AuthPlugin(Plugin):
MethodNotAllowed: If not supported by Backend MethodNotAllowed: If not supported by Backend
Any valid HTTP exception Any valid HTTP exception
""" """
# By default save the image to the avatar, from flaschengeist.controller import imageController
# deleting would happen by unsetting it
from ..controller import imageController
user.avatar_ = imageController.upload_image(file) user.avatar_ = imageController.upload_image(file)

View File

@ -105,7 +105,7 @@ def frontend(userid, current_session):
raise Forbidden raise Forbidden
if request.method == "POST": if request.method == "POST":
if request.content_length > 1024**2: if request.content_length > 1024 ** 2:
raise BadRequest raise BadRequest
current_session.user_.set_attribute("frontend", request.get_json()) current_session.user_.set_attribute("frontend", request.get_json())
return no_content() return no_content()

View File

@ -19,7 +19,7 @@ classifiers =
[options] [options]
include_package_data = True include_package_data = True
python_requires = >=3.9 python_requires = >=3.7
packages = find: packages = find:
install_requires = install_requires =
Flask >= 2.0 Flask >= 2.0