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
commands:
- 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:
- 3.10
- 3.9
- 3.8
- 3.7

View File

@ -3,33 +3,27 @@
This is the backend of the Flaschengeist.
## Installation
### Requirements
- `mysql` or `mariadb`
- maybe `libmariadb` development files[1]
- python 3.9+
- pip 21.0+
# Installation
## Main package
### System dependencies
- **python 3.7+**
- Database (MySQL / mariadb by default)
*[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on
Linux / Mac 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.*
By default Flaschengeist uses mysql as database backend,
if you are on Windows Flaschengeist uses `PyMySQL`, which does not require any other system packages.
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
It is recommended to upgrade pip to the latest version before installing:
python -m pip install --upgrade pip
Default installation with *mariadb*/*mysql* support:
pip3 install --user ".[mysql]"
pip3 install --user .
or with ldap support
pip3 install --user ".[ldap]"
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:
- `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
### 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
or with debug messages:

View File

@ -1,9 +1,10 @@
"""Flaschengeist"""
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__ = {}
logger = logging.getLogger(__name__)
logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__))
__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 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.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):
@ -67,14 +67,14 @@ def load_plugins(app: Flask):
def create_app(test_config=None, cli=False):
app = Flask("flaschengeist")
app = Flask(__name__)
app.json_encoder = CustomJSONEncoder
CORS(app)
with app.app_context():
from flaschengeist.database import db, migrate
configure_app(app, test_config)
configure_app(app, test_config, cli)
db.init_app(app)
migrate.init_app(app, db, compare_type=True)
load_plugins(app)

View File

@ -1,14 +1,7 @@
from os import environ
import sys
import click
import logging
from flask.cli import FlaskGroup, with_appcontext
from flaschengeist import logger
from flaschengeist.app import create_app
LOGGING_MIN = 5 # TRACE (custom)
LOGGING_MAX = logging.ERROR
from flaschengeist.config import configure_logger
def get_version(ctx, param, value):
@ -30,37 +23,19 @@ def get_version(ctx, param, value):
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
def verbosity(ctx, param, value):
"""Callback: Toggle verbosity between ERROR <-> TRACE"""
"""Toggle verbosity between WARNING <-> DEBUG"""
if not value or ctx.resilient_parsing:
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(
cls=FlaskGroup,
add_version_option=False,
add_default_commands=False,
create_app=create_app,
create_app=lambda: create_app(cli=30),
)
@click.option(
"--version",
@ -84,15 +59,12 @@ def cli():
def main(*args, **kwargs):
# from .plugin_cmd import plugin
from .plugin_cmd import plugin
from .export_cmd import export
from .docs_cmd import docs
from .run_cmd import run
# Override logging level
environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX))
# cli.add_command(plugin)
cli.add_command(plugin)
cli.add_command(export)
cli.add_command(docs)
cli.add_command(run)

View File

@ -28,11 +28,13 @@ class PrefixMiddleware(object):
@click.pass_context
def run(ctx, host, port, debug):
"""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", ""))
if debug:
environ["FLASK_DEBUG"] = "1"
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 toml
import logging.config
import collections.abc
from pathlib import Path
from logging.config import dictConfig
from werkzeug.middleware.proxy_fix import ProxyFix
from flaschengeist import logger
# Default config:
config = {"DATABASE": {"engine": "mysql", "port": 3306}}
@ -41,41 +41,36 @@ def read_configuration(test_config):
update_dict(config, test_config)
def configure_logger():
"""Configure the logger
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
def configure_logger(cli=False):
global config
# Read default config
logger_config = toml.load(Path(__file__).parent / "logging.toml")
if "LOGGING" in config:
# Override with user config
update_dict(logger_config, config.get("LOGGING"))
# Check for shortcuts
if "level" in config["LOGGING"]:
set_level(config["LOGGING"]["level"])
# Override logging, used e.g. by CLI
if "FG_LOGGING" in os.environ:
set_level(os.environ.get("FG_LOGGING", "CRITICAL"))
dictConfig(logger_config)
if "level" in config["LOGGING"] or isinstance(cli, int):
level = cli if cli and isinstance(cli, int) else config["LOGGING"]["level"]
logger_config["loggers"]["flaschengeist"] = {"level": level}
logger_config["handlers"]["console"]["level"] = level
logger_config["handlers"]["file"]["level"] = level
if cli is True or not config["LOGGING"].get("console", True):
logger_config["handlers"]["console"]["level"] = "CRITICAL"
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
read_configuration(test_config)
configure_logger()
configure_logger(cli)
# Always enable this builtin plugins!
update_dict(
@ -89,9 +84,9 @@ def configure_app(app, test_config=None):
)
if "secret_key" not in config["FLASCHENGEIST"]:
logger.critical("No secret key was configured, please configure one for production systems!")
raise RuntimeError("No secret key was configured")
logger.warning("No secret key was configured, please configure one for production systems!")
app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29"
else:
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
if test_config is not None:

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"
# 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]
# engine = "mysql" (default)
host = "localhost"
@ -19,22 +36,6 @@ user = "flaschengeist"
password = "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]
# Path for file / image uploads
data_path = "./data"
@ -48,11 +49,6 @@ allowed_mimetypes = [
"image/webp"
]
[scheduler]
# Possible values are: "passive_web" (default), "active_web" and "system"
# See documentation
# cron = "passive_web"
[auth_ldap]
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
# host = "localhost"

View File

@ -6,16 +6,22 @@ disable_existing_loggers = false
[formatters]
[formatters.simple]
format = "[%(asctime)s] %(levelname)s - %(message)s"
format = "%(asctime)s - %(levelname)s - %(message)s"
[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.wsgi]
stream = "ext://flask.logging.wsgi_errors_stream"
[handlers.console]
class = "logging.StreamHandler"
formatter = "simple"
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.werkzeug]
@ -23,4 +29,4 @@ disable_existing_loggers = false
[root]
level = "WARNING"
handlers = ["wsgi"]
handlers = ["console"]

View File

@ -24,6 +24,7 @@ For more information, please refer to
- `flaschengeist.utils.hook.HookAfter`
"""
import sqlalchemy
from werkzeug.exceptions import MethodNotAllowed, NotFound
from flaschengeist.utils.hook import HookBefore, HookAfter
@ -166,9 +167,20 @@ class Plugin:
Raises:
`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):
"""Save setting in database
@ -177,9 +189,22 @@ class Plugin:
name: String identifying the setting
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):
"""Create a new notification for an user
@ -193,9 +218,14 @@ class Plugin:
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):
"""Serialize a plugin into a dict
@ -301,9 +331,7 @@ class AuthPlugin(Plugin):
MethodNotAllowed: If not supported by Backend
Any valid HTTP exception
"""
# By default save the image to the avatar,
# deleting would happen by unsetting it
from ..controller import imageController
from flaschengeist.controller import imageController
user.avatar_ = imageController.upload_image(file)

View File

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

View File

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