Compare commits

..

20 Commits

Author SHA1 Message Date
Ferdinand Thiessen 2fcc7ffc5b fix(app): Skip plugins with not satisfied dependencies.
ci/woodpecker/push/lint Pipeline failed Details
ci/woodpecker/push/test Pipeline was successful Details
ci/woodpecker/pr/lint Pipeline failed Details
ci/woodpecker/pr/test Pipeline was successful Details
2022-02-22 23:40:40 +01:00
Ferdinand Thiessen 05667787de fix(plugins): Fix plugin version for plugin list API endpoint 2022-02-22 23:40:40 +01:00
Ferdinand Thiessen 46ecfcd62a feat(cli): Added CLI command for handling plugins
* Install / Uninstall plugins
* List plugins
2022-02-22 23:40:40 +01:00
Ferdinand Thiessen 76f660c160 feat(plugins): Identify plugins by id, migrations must be provided at defined location, add utils for plugin functions 2022-02-22 23:40:38 +01:00
Ferdinand Thiessen c137765fbe fix(docs): Various documentation fixed and improvments 2022-02-22 23:27:07 +01:00
Ferdinand Thiessen 44a17d7155 docs(migrations): Some documentation ++ 2022-02-22 23:25:45 +01:00
Ferdinand Thiessen 9226fb6bea feat(docs): Add documentation on how to install tables 2022-02-22 23:24:14 +01:00
Ferdinand Thiessen d8f21d6c0a fix(db): Remove print statement for debugging 2022-02-22 23:24:14 +01:00
Ferdinand Thiessen ef6a2b32c6 feat(db): Add initial migrations for core Flaschengeist + balance and pricelist plugins 2022-02-22 23:24:14 +01:00
Ferdinand Thiessen 84fef2b49a feat(db): Add migrations support to plugins 2022-02-22 23:24:14 +01:00
Ferdinand Thiessen f1df5076ed fix(db): Add __repr__ to custom column types, same as done by SQLAlchemy 2022-02-22 23:24:14 +01:00
Ferdinand Thiessen 35a2f25e71 feat(db): Add database migration support, implements #19
Migrations allow us to keep track of database changes and upgrading databases if needed.
2022-02-22 23:24:14 +01:00
Ferdinand Thiessen e510c54bd8 chore(clean): Fix codestyle of config.py
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
2022-02-22 11:11:23 +01:00
Ferdinand Thiessen c5db932065 chore(clean): Drop `_module_path` from flaschengeist 2022-02-21 22:24:33 +01:00
Ferdinand Thiessen 1484d678ce feat(security): Enforce secret key for flask application. 2022-02-21 21:03:15 +01:00
Ferdinand Thiessen e82d830410 fix(app): Fix import_name for flask application 2022-02-21 21:02:45 +01:00
Ferdinand Thiessen 760ee9fe36 fix(cli): Fix logging when setting verbosity on the cli 2022-02-21 21:02:15 +01:00
Ferdinand Thiessen 90999bbefb chore(core): Seperated logic from the plugin code, reduces imports 2022-02-13 14:31:55 +01:00
Ferdinand Thiessen fb50ed05be deps: Require at lease python 3.9, fixes #22
continuous-integration/woodpecker the build was successful Details
2021-12-26 15:44:04 +01:00
Ferdinand Thiessen 54a789b772 fix(docs): PIP 21.0+ is required, some minor improvements 2021-12-26 15:42:31 +01:00
14 changed files with 216 additions and 130 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=py37 . - black --check --line-length 120 --target-version=py39 .

View File

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

View File

@ -3,27 +3,33 @@
This is the backend of the Flaschengeist. This is the backend of the Flaschengeist.
# Installation ## Installation
## Main package ### Requirements
### System dependencies - `mysql` or `mariadb`
- **python 3.7+** - maybe `libmariadb` development files[1]
- Database (MySQL / mariadb by default) - python 3.9+
- pip 21.0+
By default Flaschengeist uses mysql as database backend, *[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on
if you are on Windows Flaschengeist uses `PyMySQL`, which does not require any other system packages. 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.*
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
pip3 install --user . 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]"
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,test]" pip3 install --user ".[ldap,tests]"
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)
@ -75,6 +81,12 @@ 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,10 +1,9 @@
"""Flaschengeist""" """Flaschengeist"""
import logging import logging
import pkg_resources from importlib.metadata import version
from werkzeug.local import LocalProxy
__version__ = pkg_resources.get_distribution("flaschengeist").version __version__ = version("flaschengeist")
__pdoc__ = {} __pdoc__ = {}
logger: logging.Logger = LocalProxy(lambda: logging.getLogger(__name__)) logger = 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 . import logger from flaschengeist.config import config, configure_app
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(__name__) app = Flask("flaschengeist")
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, cli) 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)

View File

@ -1,7 +1,14 @@
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):
@ -23,19 +30,37 @@ 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):
"""Toggle verbosity between WARNING <-> DEBUG""" """Callback: Toggle verbosity between ERROR <-> TRACE"""
if not value or ctx.resilient_parsing: if not value or ctx.resilient_parsing:
return return
configure_logger(cli=30 - max(0, min(value * 10, 20))) configure_logger(LOGGING_MAX - max(LOGGING_MIN, min(value * 10, LOGGING_MAX - LOGGING_MIN)))
@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=lambda: create_app(cli=30), create_app=create_app,
) )
@click.option( @click.option(
"--version", "--version",
@ -59,12 +84,15 @@ 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
cli.add_command(plugin) # Override logging level
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,13 +28,11 @@ 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, configure_logger from flaschengeist.config import config
# 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, host=host, port=port, debugger=debug) ctx.invoke(run_command, reload=True, host=host, port=port, debugger=debug)

View File

@ -1,12 +1,12 @@
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,36 +41,41 @@ def read_configuration(test_config):
update_dict(config, test_config) update_dict(config, test_config)
def configure_logger(cli=False): def configure_logger():
global config """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
# 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"] or isinstance(cli, int): if "level" in config["LOGGING"]:
level = cli if cli and isinstance(cli, int) else config["LOGGING"]["level"] set_level(config["LOGGING"]["level"])
logger_config["loggers"]["flaschengeist"] = {"level": level}
logger_config["handlers"]["console"]["level"] = level # Override logging, used e.g. by CLI
logger_config["handlers"]["file"]["level"] = level if "FG_LOGGING" in os.environ:
if cli is True or not config["LOGGING"].get("console", True): set_level(os.environ.get("FG_LOGGING", "CRITICAL"))
logger_config["handlers"]["console"]["level"] = "CRITICAL"
if not cli and isinstance(config["LOGGING"].get("file", False), str): dictConfig(logger_config)
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, cli=False): def configure_app(app, test_config=None):
global config global config
read_configuration(test_config) read_configuration(test_config)
configure_logger(cli) configure_logger()
# Always enable this builtin plugins! # Always enable this builtin plugins!
update_dict( update_dict(
@ -84,10 +89,10 @@ def configure_app(app, test_config=None, cli=False):
) )
if "secret_key" not in config["FLASCHENGEIST"]: if "secret_key" not in config["FLASCHENGEIST"]:
logger.warning("No secret key was configured, please configure one for production systems!") logger.critical("No secret key was configured, please configure one for production systems!")
app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" raise RuntimeError("No secret key was configured")
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

@ -0,0 +1,76 @@
"""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,23 +12,6 @@ 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"
@ -36,6 +19,22 @@ 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"
@ -49,6 +48,11 @@ 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,22 +6,16 @@ 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 — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" format = "[%(asctime)s] %(levelname)s %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(message)s"
[handlers] [handlers]
[handlers.console] [handlers.wsgi]
stream = "ext://flask.logging.wsgi_errors_stream"
class = "logging.StreamHandler" class = "logging.StreamHandler"
level = "DEBUG"
formatter = "simple" formatter = "simple"
stream = "ext://sys.stderr" level = "DEBUG"
[handlers.file]
class = "logging.handlers.WatchedFileHandler"
level = "WARNING"
formatter = "extended"
encoding = "utf8"
filename = "flaschengeist.log"
[loggers] [loggers]
[loggers.werkzeug] [loggers.werkzeug]
@ -29,4 +23,4 @@ disable_existing_loggers = false
[root] [root]
level = "WARNING" level = "WARNING"
handlers = ["console"] handlers = ["wsgi"]

View File

@ -24,7 +24,6 @@ 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
@ -167,20 +166,9 @@ class Plugin:
Raises: Raises:
`KeyError` if no such setting exists in the database `KeyError` if no such setting exists in the database
""" """
from flaschengeist.models.setting import _PluginSetting from ..controller import pluginController
try: return pluginController.get_setting(self.id)
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
@ -189,22 +177,9 @@ class Plugin:
name: String identifying the setting name: String identifying the setting
value: Value to be stored value: Value to be stored
""" """
from flaschengeist.models.setting import _PluginSetting from ..controller import pluginController
from flaschengeist.database import db
setting = ( return pluginController.set_setting(self.id, name, value)
_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
@ -218,14 +193,9 @@ class Plugin:
Hint: use the data for frontend actions. Hint: use the data for frontend actions.
""" """
from flaschengeist.models.notification import Notification from ..controller import pluginController
from flaschengeist.database import db
if not user.deleted: return pluginController.notify(self.id, user, text, data)
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
@ -331,7 +301,9 @@ class AuthPlugin(Plugin):
MethodNotAllowed: If not supported by Backend MethodNotAllowed: If not supported by Backend
Any valid HTTP exception Any valid HTTP exception
""" """
from flaschengeist.controller import imageController # By default save the image to the avatar,
# 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.7 python_requires = >=3.9
packages = find: packages = find:
install_requires = install_requires =
Flask >= 2.0 Flask >= 2.0