Compare commits

...

9 Commits

Author SHA1 Message Date
Ferdinand Thiessen aeb4c39a12 fix(app): Skip plugins with not satisfied dependencies.
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
ci/woodpecker/pr/lint Pipeline was successful Details
ci/woodpecker/pr/test Pipeline was successful Details
2022-02-23 00:04:49 +01:00
Ferdinand Thiessen a9674d68ab fix(plugins): Fix plugin version for plugin list API endpoint 2022-02-23 00:04:49 +01:00
Ferdinand Thiessen f0751a3cc5 feat(cli): Added CLI command for handling plugins
* Install / Uninstall plugins
* List plugins
2022-02-23 00:04:49 +01:00
Ferdinand Thiessen a356ef99b7 feat(plugins): Identify plugins by id, migrations must be provided at defined location, add utils for plugin functions 2022-02-23 00:04:47 +01:00
Ferdinand Thiessen d1114db06b feat(docs): Add documentation on how to install tables
Also various documentation fixed and improvments
2022-02-22 23:54:57 +01:00
Ferdinand Thiessen b250569718 feat(db): Add migrations support to plugins
* Add initial migrations for core Flaschengeist
* Add migrations to balance
* Add migrations to pricelist
2022-02-22 23:51:31 +01:00
Ferdinand Thiessen f82f81e09e fix(db): Add __repr__ to custom column types, same as done by SQLAlchemy 2022-02-22 23:50:44 +01:00
Ferdinand Thiessen bb92f15636 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:50:41 +01:00
Ferdinand Thiessen 3589564b7f feat(plugins): Load metadata from entry points / distribution
ci/woodpecker/push/lint Pipeline was successful Details
ci/woodpecker/push/test Pipeline was successful Details
ci/woodpecker/pr/lint Pipeline was successful Details
ci/woodpecker/pr/test Pipeline was successful Details
2022-02-22 11:12:33 +01:00
25 changed files with 911 additions and 181 deletions

View File

@ -31,18 +31,35 @@ or if you want to also run the tests:
pip3 install --user ".[ldap,tests]"
You will also need a MySQL driver, recommended drivers are
- `mysqlclient`
- `PyMySQL`
You will also need a MySQL driver, by default one of this is installed:
- `mysqlclient` (non Windows)
- `PyMySQL` (on Windows)
`setup.py` will try to install a matching driver.
#### Hint on MySQL driver on Windows:
If you want to use `mysqlclient` instead of `PyMySQL` (performance?) you have to follow [this guide](https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/)
#### Windows
Same as above, but if you want to use `mysqlclient` instead of `PyMySQL` (performance?) you have to follow this guide:
### Install database
The user needs to have full permissions to the database.
If not you need to create user and database manually do (or similar on Windows):
https://www.radishlogic.com/coding/python-3/installing-mysqldb-for-python-3-in-windows/
(
echo "CREATE DATABASE flaschengeist;"
echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';"
echo "GRANT ALL PRIVILEGES ON flaschengeist.* TO 'flaschengeist'@'localhost';"
echo "FLUSH PRIVILEGES;"
) | sudo mysql
### Configuration
Then you can install the database tables, this will update all tables from core + all enabled plugins.
*Hint:* The same command can be later used to upgrade the database after plugins or core are updated.
$ flaschengeist db upgrade heads
## Plugins
To only upgrade one plugin (for example the `events` plugin):
$ flaschengeist db upgrade events@head
## Configuration
Configuration is done within the a `flaschengeist.toml`file, you can copy the one located inside the module path
(where flaschegeist is installed) or create an empty one and place it inside either:
1. `~/.config/`
@ -63,21 +80,6 @@ So you have to configure one of the following options to call flaschengeists CRO
- Pros: Guaranteed execution interval, no impact on user experience (at least if you do not limit wsgi worker threads)
- Cons: Uses one of the webserver threads while executing
### Database installation
The user needs to have full permissions to the database.
If not you need to create user and database manually do (or similar on Windows):
(
echo "CREATE DATABASE flaschengeist;"
echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';"
echo "GRANT ALL PRIVILEGES ON flaschengeist.* TO 'flaschengeist'@'localhost';"
echo "FLUSH PRIVILEGES;"
) | sudo mysql
Then you can install the database tables and initial entries:
$ flaschengeist install
### Run
Flaschengeist provides a CLI, based on the flask CLI, respectivly called `flaschengeist`.

View File

@ -1,17 +1,16 @@
import enum
import pkg_resources
from flask import Flask, current_app
from flask import Flask
from flask_cors import CORS
from datetime import datetime, date
from flask.json import JSONEncoder, jsonify
from importlib_metadata import entry_points
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
from flaschengeist.controller import roleController
from flaschengeist.config import config, configure_app
@ -37,22 +36,18 @@ class CustomJSONEncoder(JSONEncoder):
@Hook("plugins.loaded")
def __load_plugins(app):
logger.debug("Search for plugins")
def load_plugins(app: Flask):
app.config["FG_PLUGINS"] = {}
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"):
logger.debug(f"Found plugin: >{entry_point.name}<")
for entry_point in entry_points(group="flaschengeist.plugins"):
logger.debug(f"Found plugin: {entry_point.name} ({entry_point.dist.version})")
if entry_point.name == config["FLASCHENGEIST"]["auth"] or (
entry_point.name in config and config[entry_point.name].get("enabled", False)
):
logger.debug(f"Load plugin {entry_point.name}")
try:
plugin = entry_point.load()
if not hasattr(plugin, "name"):
setattr(plugin, "name", entry_point.name)
plugin = plugin(config.get(entry_point.name, {}))
plugin = entry_point.load()(entry_point, config=config.get(entry_point.name, {}))
if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint)
except:
@ -75,37 +70,22 @@ def __load_plugins(app):
else:
logger.debug(f"Skip disabled plugin {entry_point.name}")
if "FG_AUTH_BACKEND" not in app.config:
logger.error("No authentication plugin configured or authentication plugin not found")
logger.fatal("No authentication plugin configured or authentication plugin not found")
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
@Hook("plugins.installed")
def install_all():
from flaschengeist.database import db
db.create_all()
db.session.commit()
for name, plugin in current_app.config["FG_PLUGINS"].items():
if not plugin:
logger.debug(f"Skip disabled plugin: {name}")
continue
logger.info(f"Install plugin {name}")
plugin.install()
if plugin.permissions:
roleController.create_permissions(plugin.permissions)
def create_app(test_config=None):
def create_app(test_config=None, cli=False):
app = Flask("flaschengeist")
app.json_encoder = CustomJSONEncoder
CORS(app)
with app.app_context():
from flaschengeist.database import db
from flaschengeist.database import db, migrate
configure_app(app, test_config)
db.init_app(app)
__load_plugins(app)
migrate.init_app(app, db, compare_type=True)
load_plugins(app)
@app.route("/", methods=["GET"])
def __get_state():

View File

@ -0,0 +1,83 @@
import click
from click.decorators import pass_context
from flask import current_app
from flask.cli import with_appcontext
from flaschengeist.utils.plugin import get_plugins, plugin_version
@click.group()
def plugin():
pass
@plugin.command()
@click.argument("plugin", nargs=-1, type=str)
@click.option("--all", help="Install all enabled plugins", is_flag=True)
@with_appcontext
@pass_context
def install(ctx, plugin, all):
"""Install one or more plugins"""
if not all and len(plugin) == 0:
ctx.fail("At least one plugin must be specified, or use `--all` flag.")
if all:
plugins = current_app.config["FG_PLUGINS"].values()
else:
try:
plugins = [current_app.config["FG_PLUGINS"][p] for p in plugin]
except KeyError as e:
ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<")
for p in plugins:
name = p.id.split(".")[-1]
click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False)
p.install()
click.secho(" ok", fg="green")
@plugin.command()
@click.argument("plugin", nargs=-1, type=str)
@with_appcontext
@pass_context
def uninstall(ctx: click.Context, plugin):
"""Uninstall one or more plugins"""
if len(plugin) == 0:
ctx.fail("At least one plugin must be specified")
try:
plugins = [current_app.config["FG_PLUGINS"][p] for p in plugin]
except KeyError as e:
ctx.fail(f"Invalid plugin ID, could not find >{e.args[0]}<")
if (
click.prompt(
"You are going to uninstall:\n\n"
f"\t{', '.join([p.id.split('.')[-1] for p in plugins])}\n\n"
"Are you sure?",
default="n",
show_choices=True,
type=click.Choice(["y", "N"], False),
).lower()
!= "y"
):
ctx.exit()
for p in plugins:
name = p.id.split(".")[-1]
click.echo(f"Uninstalling {name}{'.'*(20-len(name))}", nl=False)
p.uninstall()
click.secho(" ok", fg="green")
@plugin.command()
@click.option("--enabled", "-e", help="List only enabled plugins", is_flag=True)
@with_appcontext
def ls(enabled):
if enabled:
plugins = current_app.config["FG_PLUGINS"].values()
else:
plugins = get_plugins()
print(f"{' '*13}{'name': <20}|{'version': >10}")
print("-" * 46)
for plugin in plugins:
print(
f"{plugin.id: <33}|{plugin_version(plugin): >12}"
f"{click.style(' (enabled)', fg='green') if plugin.id in current_app.config['FG_PLUGINS'] else click.style(' (disabled)', fg='red')}"
)

View File

@ -1,3 +1,6 @@
import os
from flask import current_app
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
@ -14,6 +17,36 @@ metadata = MetaData(
db = SQLAlchemy(metadata=metadata)
migrate = Migrate()
@migrate.configure
def configure_alembic(config):
"""Alembic configuration hook
Inject all migrations paths into the ``version_locations`` config option.
This includes even disabled plugins, as simply disabling a plugin without
uninstall can break the alembic version management.
"""
import inspect, pathlib
from flaschengeist.utils.plugin import get_plugins
# Load migration paths from plugins
migrations = [(pathlib.Path(inspect.getfile(p)).parent / "migrations") for p in get_plugins()]
migrations = [str(m.resolve()) for m in migrations if m.exists()]
if len(migrations) > 0:
# Get configured paths
paths = config.get_main_option("version_locations")
# Get configured path seperator
sep = config.get_main_option("version_path_separator", "os")
if paths:
# Insert configured paths at the front, before plugin migrations
migrations.insert(0, config.get_main_option("version_locations"))
sep = os.pathsep if sep == "os" else " " if sep == "space" else sep
# write back seperator (we changed it if neither seperator nor locations were specified)
config.set_main_option("version_path_separator", sep)
config.set_main_option("version_locations", sep.join(migrations))
return config
def case_sensitive(s):

View File

@ -1,7 +1,7 @@
import sys
import datetime
from sqlalchemy import BigInteger
from sqlalchemy import BigInteger, util
from sqlalchemy.dialects import mysql, sqlite
from sqlalchemy.types import DateTime, TypeDecorator
@ -50,6 +50,10 @@ class Serial(TypeDecorator):
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
@ -85,3 +89,7 @@ class UtcDateTime(TypeDecorator):
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,75 +1,138 @@
import pkg_resources
from werkzeug.datastructures import FileStorage
"""Flaschengeist Plugins
## Custom database tables
You can add tables by declaring them using the SQLAlchemy syntax,
then use Alembic to generate migrations for your tables.
This allows Flaschengeist to proper up- or downgrade the
database tables if an user updates your plugin.
migrations have to be provided in a directory called `migrations`
next to your plugin. E.G.
myplugin
- __init__.py
- other/
- ...
- migrations/
## Useful Hooks
There are some predefined hooks, which might get handy for you.
For more information, please refer to
- `flaschengeist.utils.hook.HookBefore` and
- `flaschengeist.utils.hook.HookAfter`
"""
from importlib_metadata import Distribution, EntryPoint
from werkzeug.exceptions import MethodNotAllowed, NotFound
from werkzeug.datastructures import FileStorage
from flaschengeist.models.user import _Avatar, User
from flaschengeist.utils.hook import HookBefore, HookAfter
__all__ = [
"plugins_installed",
"plugins_loaded",
"before_delete_user",
"before_role_updated",
"before_update_user",
"after_role_updated",
"Plugin",
"AuthPlugin",
]
# Documentation hacks, see https://github.com/mitmproxy/pdoc/issues/320
plugins_installed = HookAfter("plugins.installed")
"""Hook decorator for when all plugins are installed
Possible use case would be to populate the database with some presets.
plugins_installed.__doc__ = """Hook decorator for when all plugins are installed
Args:
hook_result: void (kwargs)
Possible use case would be to populate the database with some presets.
"""
plugins_loaded = HookAfter("plugins.loaded")
"""Hook decorator for when all plugins are loaded
Possible use case would be to check if a specific other plugin is loaded and change own behavior
plugins_loaded.__doc__ = """Hook decorator for when all plugins are loaded
Args:
app: Current flask app instance (args)
hook_result: void (kwargs)
Possible use case would be to check if a specific other plugin is loaded and change own behavior
Passed args:
- *app:* Current flask app instance (args)
"""
before_role_updated = HookBefore("update_role")
"""Hook decorator for when roles are modified
Args:
role: Role object to modify
new_name: New name if the name was changed (None if delete)
before_role_updated.__doc__ = """Hook decorator for when roles are modified
Passed args:
- *role:* `flaschengeist.models.user.Role` to modify
- *new_name:* New name if the name was changed (*None* if delete)
"""
after_role_updated = HookAfter("update_role")
"""Hook decorator for when roles are modified
Args:
role: Role object containing the modified role
new_name: New name if the name was changed (None if deleted)
after_role_updated.__doc__ = """Hook decorator for when roles are modified
Passed args:
- *role:* modified `flaschengeist.models.user.Role`
- *new_name:* New name if the name was changed (*None* if deleted)
"""
before_update_user = HookBefore("update_user")
"""Hook decorator, when ever an user update is done, this is called before.
Args:
user: User object
before_update_user.__doc__ = """Hook decorator, when ever an user update is done, this is called before.
Passed args:
- *user:* `flaschengeist.models.user.User` object
"""
before_delete_user = HookBefore("delete_user")
"""Hook decorator,this is called before an user gets deleted.
Args:
user: User object
before_delete_user.__doc__ = """Hook decorator,this is called before an user gets deleted.
Passed args:
- *user:* `flaschengeist.models.user.User` object
"""
class Plugin:
"""Base class for all Plugins
If your class uses custom models add a static property called ``models``"""
blueprint = None # You have to override
"""Override with a `flask.blueprint` if the plugin uses custom routes"""
permissions = [] # You have to override
"""Override to add custom permissions used by the plugin
All plugins must derived from this class.
Optional:
- *blueprint*: `flask.Blueprint` providing your routes
- *permissions*: List of your custom permissions
- *models*: Your models, used for API export
"""
name: str
"""Name of the plugin, loaded from EntryPoint"""
version: str
"""Version of the plugin, loaded from Distribution"""
dist: Distribution
"""Distribution of this plugin"""
blueprint = None
"""Optional `flask.blueprint` if the plugin uses custom routes"""
permissions: list[str] = []
"""Optional list of custom permissions used by the plugin
A good style is to name the permissions with a prefix related to the plugin name,
to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*.
"""
id = "dev.flaschengeist.plugin" # You have to override
"""Override with the unique ID of the plugin (Hint: FQN)"""
name = "plugin" # You have to override
"""Override with human readable name of the plugin"""
models = None # You have to override
"""Override with models module"""
migrations_path = None # Override this with the location of your db migrations directory
"""Override with path to migration files, if custome db tables are used"""
def __init__(self, config=None):
models = None
"""Optional module containing the SQLAlchemy models used by the plugin"""
migrations_path = None
"""Optional location of the path to migration files, required if custome db tables are used"""
def __init__(self, entry_point: EntryPoint, config=None):
"""Constructor called by create_app
Args:
config: Dict configuration containing the plugin section
"""
self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version
self.version = entry_point.dist.version
self.name = entry_point.name
self.dist = entry_point.dist
def install(self):
"""Installation routine
@ -78,6 +141,17 @@ class Plugin:
"""
pass
def uninstall(self):
"""Uninstall routine
If the plugin has custom database tables, make sure to remove them.
This can be either done by downgrading the plugin *head* to the *base*.
Or use custom migrations for the uninstall and *stamp* some version.
Is always called with Flask application context.
"""
pass
def get_setting(self, name: str, **kwargs):
"""Get plugin setting from database
@ -91,7 +165,7 @@ class Plugin:
"""
from ..controller import pluginController
return pluginController.get_setting(self.id)
return pluginController.get_setting(self.id, name, **kwargs)
def set_setting(self, name: str, value):
"""Save setting in database
@ -126,10 +200,17 @@ class Plugin:
Returns:
Dict containing version and permissions of the plugin
"""
return {"version": self.version, "permissions": self.permissions}
from flaschengeist.utils.plugin import plugin_version
return {"version": plugin_version(self), "permissions": self.permissions}
class AuthPlugin(Plugin):
"""Base class for all authentification plugins
See also `Plugin`
"""
def login(self, user, pw):
"""Login routine, MUST BE IMPLEMENTED!
@ -192,7 +273,7 @@ class AuthPlugin(Plugin):
"""
raise MethodNotAllowed
def get_avatar(self, user: User) -> _Avatar:
def get_avatar(self, user):
"""Retrieve avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects,
@ -206,14 +287,14 @@ class AuthPlugin(Plugin):
"""
raise NotFound
def set_avatar(self, user: User, file: FileStorage):
def set_avatar(self, user, file: FileStorage):
"""Set the avatar for given user (if supported by auth backend)
Default behavior is to use native Image objects stored on the Flaschengeist server
Args:
user: User to set the avatar for
file: FileStorage object uploaded by the user
file: `werkzeug.datastructures.FileStorage` uploaded by the user
Raises:
MethodNotAllowed: If not supported by Backend
Any valid HTTP exception
@ -224,7 +305,7 @@ class AuthPlugin(Plugin):
user.avatar_ = imageController.upload_image(file)
def delete_avatar(self, user: User):
def delete_avatar(self, user):
"""Delete the avatar for given user (if supported by auth backend)
Default behavior is to use the imageController and native Image objects.

View File

@ -13,8 +13,8 @@ from flaschengeist.controller import sessionController, userController
class AuthRoutePlugin(Plugin):
name = "auth"
blueprint = Blueprint(name, __name__)
id = "dev.flaschengeist.auth"
blueprint = Blueprint("auth", __name__)
@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"])

View File

@ -17,8 +17,8 @@ from flaschengeist.plugins import AuthPlugin, before_role_updated
class AuthLDAP(AuthPlugin):
def __init__(self, config):
super().__init__()
def __init__(self, entry_point, config):
super().__init__(entry_point, config)
app.config.update(
LDAP_SERVER=config.get("host", "localhost"),
LDAP_PORT=config.get("port", 389),

View File

@ -14,6 +14,8 @@ from flaschengeist import logger
class AuthPlain(AuthPlugin):
id = "auth_plain"
def install(self):
plugins_installed(self.post_install)

View File

@ -3,6 +3,7 @@
Extends users plugin with balance functions
"""
import pathlib
from flask import Blueprint, current_app
from werkzeug.local import LocalProxy
from werkzeug.exceptions import NotFound
@ -56,16 +57,17 @@ def service_debit():
class BalancePlugin(Plugin):
name = "balance"
id = "dev.flaschengeist.balance"
blueprint = Blueprint(name, __name__)
permissions = permissions.permissions
plugin: "BalancePlugin" = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name])
models = models
def __init__(self, config):
super(BalancePlugin, self).__init__(config)
from . import routes
def __init__(self, entry_point, config):
super(BalancePlugin, self).__init__(entry_point, config)
from .routes import blueprint
self.blueprint = blueprint
self.migrations_path = (pathlib.Path(__file__).parent / "migrations").resolve()
@plugins_loaded
def post_loaded(*args, **kwargs):

View File

@ -0,0 +1,47 @@
"""Initial balance migration
Revision ID: f07df84f7a95
Revises:
Create Date: 2021-12-19 21:12:53.192267
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "f07df84f7a95"
down_revision = None
branch_labels = ("balance",)
depends_on = "d3026757c7cb"
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"balance_transaction",
sa.Column("receiver_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("sender_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("author_id", flaschengeist.models.Serial(), nullable=False),
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False),
sa.Column("amount", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False),
sa.Column("reversal_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["author_id"], ["user.id"], name=op.f("fk_balance_transaction_author_id_user")),
sa.ForeignKeyConstraint(["receiver_id"], ["user.id"], name=op.f("fk_balance_transaction_receiver_id_user")),
sa.ForeignKeyConstraint(
["reversal_id"],
["balance_transaction.id"],
name=op.f("fk_balance_transaction_reversal_id_balance_transaction"),
),
sa.ForeignKeyConstraint(["sender_id"], ["user.id"], name=op.f("fk_balance_transaction_sender_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_balance_transaction")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("balance_transaction")
# ### end Alembic commands ###

View File

@ -1,6 +1,6 @@
from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, BadRequest
from flask import request, jsonify
from flask import Blueprint, request, jsonify
from flaschengeist.utils import HTTP
from flaschengeist.models.session import Session
@ -18,7 +18,10 @@ def str2bool(string: str):
raise ValueError
@BalancePlugin.blueprint.route("/users/<userid>/balance/shortcuts", methods=["GET", "PUT"])
blueprint = Blueprint("balance", __package__)
@blueprint.route("/users/<userid>/balance/shortcuts", methods=["GET", "PUT"])
@login_required()
def get_shortcuts(userid, current_session: Session):
"""Get balance shortcuts of an user
@ -50,7 +53,7 @@ def get_shortcuts(userid, current_session: Session):
return HTTP.no_content()
@BalancePlugin.blueprint.route("/users/<userid>/balance/limit", methods=["GET"])
@blueprint.route("/users/<userid>/balance/limit", methods=["GET"])
@login_required()
def get_limit(userid, current_session: Session):
"""Get limit of an user
@ -73,7 +76,7 @@ def get_limit(userid, current_session: Session):
return {"limit": balance_controller.get_limit(user)}
@BalancePlugin.blueprint.route("/users/<userid>/balance/limit", methods=["PUT"])
@blueprint.route("/users/<userid>/balance/limit", methods=["PUT"])
@login_required(permissions.SET_LIMIT)
def set_limit(userid, current_session: Session):
"""Set the limit of an user
@ -99,7 +102,7 @@ def set_limit(userid, current_session: Session):
return HTTP.no_content()
@BalancePlugin.blueprint.route("/users/balance/limit", methods=["GET", "PUT"])
@blueprint.route("/users/balance/limit", methods=["GET", "PUT"])
@login_required(permission=permissions.SET_LIMIT)
def limits(current_session: Session):
"""Get, Modify limit of all users
@ -124,14 +127,14 @@ def limits(current_session: Session):
return HTTP.no_content()
@BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["GET"])
@blueprint.route("/users/<userid>/balance", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_balance(userid, current_session: Session):
"""Get balance of user, optionally filtered
Route: ``/users/<userid>/balance`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string}```
GET-parameters: ``{from?: string, to?: string}``
Args:
userid: Userid of user to get balance from
@ -162,7 +165,7 @@ def get_balance(userid, current_session: Session):
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@BalancePlugin.blueprint.route("/users/<userid>/balance/transactions", methods=["GET"])
@blueprint.route("/users/<userid>/balance/transactions", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_transactions(userid, current_session: Session):
"""Get transactions of user, optionally filtered
@ -170,7 +173,7 @@ def get_transactions(userid, current_session: Session):
Route: ``/users/<userid>/balance/transactions`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}```
GET-parameters: ``{from?: string, to?: string, limit?: int, offset?: int}``
Args:
userid: Userid of user to get transactions from
@ -223,7 +226,7 @@ def get_transactions(userid, current_session: Session):
return {"transactions": transactions, "count": count}
@BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["PUT"])
@blueprint.route("/users/<userid>/balance", methods=["PUT"])
@login_required()
def change_balance(userid, current_session: Session):
"""Change balance of an user
@ -272,7 +275,7 @@ def change_balance(userid, current_session: Session):
raise Forbidden
@BalancePlugin.blueprint.route("/balance/<int:transaction_id>", methods=["DELETE"])
@blueprint.route("/balance/<int:transaction_id>", methods=["DELETE"])
@login_required()
def reverse_transaction(transaction_id, current_session: Session):
"""Reverse a transaction
@ -297,7 +300,7 @@ def reverse_transaction(transaction_id, current_session: Session):
raise Forbidden
@BalancePlugin.blueprint.route("/balance", methods=["GET"])
@blueprint.route("/balance", methods=["GET"])
@login_required(permission=permissions.SHOW_OTHER)
def get_balances(current_session: Session):
"""Get all balances

View File

@ -12,8 +12,8 @@ from . import Plugin
class MailMessagePlugin(Plugin):
def __init__(self, config):
super().__init__()
def __init__(self, entry_point, config):
super().__init__(entry_point, config)
self.server = config["SERVER"]
self.port = config["PORT"]
self.user = config["USER"]

View File

@ -1,5 +1,6 @@
"""Pricelist plugin"""
import pathlib
from flask import Blueprint, jsonify, request, current_app
from werkzeug.local import LocalProxy
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
@ -15,21 +16,24 @@ from . import models
from . import pricelist_controller, permissions
blueprint = Blueprint("pricelist", __name__, url_prefix="/pricelist")
class PriceListPlugin(Plugin):
name = "pricelist"
permissions = permissions.permissions
blueprint = Blueprint(name, __name__, url_prefix="/pricelist")
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name])
models = models
def __init__(self, cfg):
super().__init__(cfg)
def __init__(self, entry_point, config=None):
super().__init__(entry_point, config)
self.blueprint = blueprint
self.migrations_path = (pathlib.Path(__file__).parent / "migrations").resolve()
config = {"discount": 0}
config.update(cfg)
config.update(config)
@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"])
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["GET"])
@blueprint.route("/drink-types", methods=["GET"])
@blueprint.route("/drink-types/<int:identifier>", methods=["GET"])
def get_drink_types(identifier=None):
"""Get DrinkType(s)
@ -49,7 +53,7 @@ def get_drink_types(identifier=None):
return jsonify(result)
@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"])
@blueprint.route("/drink-types", methods=["POST"])
@login_required(permission=permissions.CREATE_TYPE)
def new_drink_type(current_session):
"""Create new DrinkType
@ -71,7 +75,7 @@ def new_drink_type(current_session):
return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["PUT"])
@blueprint.route("/drink-types/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TYPE)
def update_drink_type(identifier, current_session):
"""Modify DrinkType
@ -94,7 +98,7 @@ def update_drink_type(identifier, current_session):
return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"])
@blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TYPE)
def delete_drink_type(identifier, current_session):
"""Delete DrinkType
@ -112,8 +116,8 @@ def delete_drink_type(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/tags", methods=["GET"])
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["GET"])
@blueprint.route("/tags", methods=["GET"])
@blueprint.route("/tags/<int:identifier>", methods=["GET"])
def get_tags(identifier=None):
"""Get Tag(s)
@ -133,7 +137,7 @@ def get_tags(identifier=None):
return jsonify(result)
@PriceListPlugin.blueprint.route("/tags", methods=["POST"])
@blueprint.route("/tags", methods=["POST"])
@login_required(permission=permissions.CREATE_TAG)
def new_tag(current_session):
"""Create Tag
@ -153,7 +157,7 @@ def new_tag(current_session):
return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["PUT"])
@blueprint.route("/tags/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TAG)
def update_tag(identifier, current_session):
"""Modify Tag
@ -174,7 +178,7 @@ def update_tag(identifier, current_session):
return jsonify(tag)
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["DELETE"])
@blueprint.route("/tags/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TAG)
def delete_tag(identifier, current_session):
"""Delete Tag
@ -192,8 +196,8 @@ def delete_tag(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/drinks", methods=["GET"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["GET"])
@blueprint.route("/drinks", methods=["GET"])
@blueprint.route("/drinks/<int:identifier>", methods=["GET"])
def get_drinks(identifier=None):
"""Get Drink(s)
@ -249,7 +253,7 @@ def get_drinks(identifier=None):
return jsonify({"drinks": drinks, "count": count})
@PriceListPlugin.blueprint.route("/list", methods=["GET"])
@blueprint.route("/list", methods=["GET"])
def get_pricelist():
"""Get Priclist
Route: ``/pricelist/list`` | Method: ``GET``
@ -298,7 +302,7 @@ def get_pricelist():
return jsonify({"pricelist": pricelist, "count": count})
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
@blueprint.route("/drinks/search/<string:name>", methods=["GET"])
def search_drinks(name):
"""Search Drink
@ -319,7 +323,7 @@ def search_drinks(name):
return jsonify(pricelist_controller.get_drinks(name, public=public))
@PriceListPlugin.blueprint.route("/drinks", methods=["POST"])
@blueprint.route("/drinks", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_drink(current_session):
"""Create Drink
@ -371,7 +375,7 @@ def create_drink(current_session):
return jsonify(pricelist_controller.set_drink(data))
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["PUT"])
@blueprint.route("/drinks/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def update_drink(identifier, current_session):
"""Modify Drink
@ -425,7 +429,7 @@ def update_drink(identifier, current_session):
return jsonify(pricelist_controller.update_drink(identifier, data))
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["DELETE"])
@blueprint.route("/drinks/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_drink(identifier, current_session):
"""Delete Drink
@ -443,7 +447,7 @@ def delete_drink(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/prices/<int:identifier>", methods=["DELETE"])
@blueprint.route("/prices/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_PRICE)
def delete_price(identifier, current_session):
"""Delete Price
@ -461,7 +465,7 @@ def delete_price(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/volumes/<int:identifier>", methods=["DELETE"])
@blueprint.route("/volumes/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_VOLUME)
def delete_volume(identifier, current_session):
"""Delete DrinkPriceVolume
@ -479,7 +483,7 @@ def delete_volume(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"])
@blueprint.route("/ingredients/extraIngredients", methods=["GET"])
@login_required()
def get_extra_ingredients(current_session):
"""Get ExtraIngredients
@ -495,7 +499,7 @@ def get_extra_ingredients(current_session):
return jsonify(pricelist_controller.get_extra_ingredients())
@PriceListPlugin.blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"])
@blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_INGREDIENTS_DRINK)
def delete_ingredient(identifier, current_session):
"""Delete Ingredient
@ -513,7 +517,7 @@ def delete_ingredient(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"])
@blueprint.route("/ingredients/extraIngredients", methods=["POST"])
@login_required(permission=permissions.EDIT_INGREDIENTS)
def set_extra_ingredient(current_session):
"""Create ExtraIngredient
@ -532,7 +536,7 @@ def set_extra_ingredient(current_session):
return jsonify(pricelist_controller.set_extra_ingredient(data))
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
@blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_INGREDIENTS)
def update_extra_ingredient(identifier, current_session):
"""Modify ExtraIngredient
@ -552,7 +556,7 @@ def update_extra_ingredient(identifier, current_session):
return jsonify(pricelist_controller.update_extra_ingredient(identifier, data))
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
@blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_INGREDIENTS)
def delete_extra_ingredient(identifier, current_session):
"""Delete ExtraIngredient
@ -570,7 +574,7 @@ def delete_extra_ingredient(identifier, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"])
@blueprint.route("/settings/min_prices", methods=["GET"])
@login_required()
def get_pricelist_settings_min_prices(current_session):
"""Get MinPrices
@ -591,7 +595,7 @@ def get_pricelist_settings_min_prices(current_session):
return jsonify(min_prices)
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"])
@blueprint.route("/settings/min_prices", methods=["POST"])
@login_required(permission=permissions.EDIT_MIN_PRICES)
def post_pricelist_settings_min_prices(current_session):
"""Create MinPrices
@ -614,7 +618,7 @@ def post_pricelist_settings_min_prices(current_session):
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
@blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
@login_required()
def get_columns(userid, current_session):
"""Get pricecalc_columns of an user
@ -646,7 +650,7 @@ def get_columns(userid, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
@blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
@login_required()
def get_columns_order(userid, current_session):
"""Get pricecalc_columns_order of an user
@ -677,7 +681,7 @@ def get_columns_order(userid, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricelist", methods=["GET", "PUT"])
@blueprint.route("/users/<userid>/pricelist", methods=["GET", "PUT"])
@login_required()
def get_priclist_setting(userid, current_session):
"""Get pricelistsetting of an user
@ -710,7 +714,7 @@ def get_priclist_setting(userid, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "DELETE"])
@blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "DELETE"])
@login_required(permission=permissions.EDIT)
def set_picture(identifier, current_session):
"""Get, Create, Delete Drink Picture
@ -737,7 +741,7 @@ def set_picture(identifier, current_session):
raise BadRequest
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["GET"])
@blueprint.route("/drinks/<int:identifier>/picture", methods=["GET"])
# @headers({"Cache-Control": "private, must-revalidate"})
def _get_picture(identifier):
"""Get Picture

View File

@ -0,0 +1,141 @@
"""Initial pricelist migration
Revision ID: 7d9d306be676
Revises:
Create Date: 2021-12-19 21:43:30.203811
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "7d9d306be676"
down_revision = None
branch_labels = ("pricelist",)
depends_on = "d3026757c7cb"
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"drink_extra_ingredient",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_extra_ingredient")),
sa.UniqueConstraint("name", name=op.f("uq_drink_extra_ingredient_name")),
)
op.create_table(
"drink_tag",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.Column("color", sa.String(length=7), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_tag")),
sa.UniqueConstraint("name", name=op.f("uq_drink_tag_name")),
)
op.create_table(
"drink_type",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_type")),
sa.UniqueConstraint("name", name=op.f("uq_drink_type_name")),
)
op.create_table(
"drink",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("article_id", sa.String(length=64), nullable=True),
sa.Column("package_size", sa.Integer(), nullable=True),
sa.Column("name", sa.String(length=60), nullable=False),
sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.Column("cost_per_volume", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True),
sa.Column("cost_per_package", sa.Numeric(precision=5, scale=3, asdecimal=False), nullable=True),
sa.Column("receipt", sa.PickleType(), nullable=True),
sa.Column("type_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("image_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["image_id"], ["image.id"], name=op.f("fk_drink_image_id_image")),
sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_type_id_drink_type")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink")),
)
op.create_table(
"drink_ingredient",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=False),
sa.Column("ingredient_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["ingredient_id"], ["drink.id"], name=op.f("fk_drink_ingredient_ingredient_id_drink")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient")),
)
op.create_table(
"drink_price_volume",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("volume", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_price_volume_drink_id_drink")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price_volume")),
)
op.create_table(
"drink_x_tag",
sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("tag_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_tag_drink_id_drink")),
sa.ForeignKeyConstraint(["tag_id"], ["drink_tag.id"], name=op.f("fk_drink_x_tag_tag_id_drink_tag")),
)
op.create_table(
"drink_x_type",
sa.Column("drink_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("type_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["drink_id"], ["drink.id"], name=op.f("fk_drink_x_type_drink_id_drink")),
sa.ForeignKeyConstraint(["type_id"], ["drink_type.id"], name=op.f("fk_drink_x_type_type_id_drink_type")),
)
op.create_table(
"drink_ingredient_association",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("_drink_ingredient_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("_extra_ingredient_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(
["_drink_ingredient_id"],
["drink_ingredient.id"],
name=op.f("fk_drink_ingredient_association__drink_ingredient_id_drink_ingredient"),
),
sa.ForeignKeyConstraint(
["_extra_ingredient_id"],
["drink_extra_ingredient.id"],
name=op.f("fk_drink_ingredient_association__extra_ingredient_id_drink_extra_ingredient"),
),
sa.ForeignKeyConstraint(
["volume_id"],
["drink_price_volume.id"],
name=op.f("fk_drink_ingredient_association_volume_id_drink_price_volume"),
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_ingredient_association")),
)
op.create_table(
"drink_price",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("price", sa.Numeric(precision=5, scale=2, asdecimal=False), nullable=True),
sa.Column("volume_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("public", sa.Boolean(), nullable=True),
sa.Column("description", sa.String(length=30), nullable=True),
sa.ForeignKeyConstraint(
["volume_id"], ["drink_price_volume.id"], name=op.f("fk_drink_price_volume_id_drink_price_volume")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_drink_price")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("drink_price")
op.drop_table("drink_ingredient_association")
op.drop_table("drink_x_type")
op.drop_table("drink_x_tag")
op.drop_table("drink_price_volume")
op.drop_table("drink_ingredient")
op.drop_table("drink")
op.drop_table("drink_type")
op.drop_table("drink_tag")
op.drop_table("drink_extra_ingredient")
# ### end Alembic commands ###

View File

@ -16,8 +16,7 @@ from . import permissions
class RolesPlugin(Plugin):
name = "roles"
blueprint = Blueprint(name, __name__)
blueprint = Blueprint("roles", __name__)
permissions = permissions.permissions

View File

@ -1,6 +1,5 @@
import pkg_resources
from datetime import datetime, timedelta
from flask import Blueprint
from datetime import datetime, timedelta
from flaschengeist import logger
from flaschengeist.utils.HTTP import no_content
@ -40,15 +39,9 @@ def scheduled(id: str, replace=False, **kwargs):
class SchedulerPlugin(Plugin):
id = "dev.flaschengeist.scheduler"
name = "scheduler"
blueprint = Blueprint(name, __name__)
def __init__(self, config=None):
"""Constructor called by create_app
Args:
config: Dict configuration containing the plugin section
"""
def __init__(self, entry_point, config=None):
super().__init__(entry_point, config)
self.blueprint = Blueprint(self.name, __name__)
def __view_func():
self.run_tasks()
@ -60,7 +53,6 @@ class SchedulerPlugin(Plugin):
except:
logger.error("Error while executing scheduled tasks!", exc_info=True)
self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version
cron = None if config is None else config.get("cron", "passive_web").lower()
if cron is None or cron == "passive_web":

View File

@ -18,8 +18,7 @@ from flaschengeist.utils.datetime import from_iso_format
class UsersPlugin(Plugin):
name = "users"
blueprint = Blueprint(name, __name__)
blueprint = Blueprint("users", __name__)
permissions = permissions.permissions

View File

@ -7,6 +7,7 @@ _hooks_after = {}
def Hook(function=None, id=None):
"""Hook decorator
Use to decorate functions as hooks, so plugins can hook up their custom functions.
"""
# `id` passed as `arg` not `kwarg`
@ -38,8 +39,10 @@ def Hook(function=None, id=None):
def HookBefore(id: str):
"""Decorator for functions to be called before a Hook-Function is called
The hooked up function must accept the same arguments as the function hooked onto,
as the functions are called with the same arguments.
Hint: This enables you to modify the arguments!
"""
if not id or not isinstance(id, str):
@ -54,9 +57,18 @@ def HookBefore(id: str):
def HookAfter(id: str):
"""Decorator for functions to be called after a Hook-Function is called
As with the HookBefore, the hooked up function must accept the same
arguments as the function hooked onto, but also receives a
`hook_result` kwarg containing the result of the function.
Example:
```py
@HookAfter("some.id")
def my_func(hook_result):
# This function is executed after the function registered with "some.id"
print(hook_result) # This is the result of the function
```
"""
if not id or not isinstance(id, str):

View File

@ -0,0 +1,49 @@
"""Plugin utils
Utilities for handling Flaschengeist plugins
"""
import pkg_resources
from flaschengeist import logger
from flaschengeist.plugins import Plugin
def get_plugins() -> list[type[Plugin]]:
"""Get all installed plugins for Flaschengeist
Returns:
list of classes implementing `flaschengeist.plugins.Plugin`
"""
logger.debug("Search for plugins")
plugins = []
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugins"):
try:
logger.debug(f"Found plugin: >{entry_point.name}<")
plugin_class = entry_point.load()
if issubclass(plugin_class, Plugin):
plugins.append(plugin_class)
except TypeError:
logger.error(f"Invalid entry point for plugin {entry_point.name} found.")
except pkg_resources.DistributionNotFound:
logger.warn(f"Requirements not fulfilled for {entry_point.name}")
logger.debug("DistributionNotFound", exc_info=True)
return plugins
def plugin_version(plugin: type[Plugin]) -> str:
"""Get version of plugin
Returns the version of a plugin, if plugin does not set the
version property, the version of the package providing the
plugin is taken.
Args:
plugin: Plugin or Plugin class
Returns:
Version as string
"""
if plugin.version:
return plugin.version
return pkg_resources.get_distribution(plugin.__module__.split(".", 1)[0]).version

52
migrations/alembic.ini Normal file
View File

@ -0,0 +1,52 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
version_path_separator = os
version_locations = %(here)s/versions
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

73
migrations/env.py Normal file
View File

@ -0,0 +1,73 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger("alembic.env")
config.set_main_option("sqlalchemy.url", str(current_app.extensions["migrate"].db.get_engine().url).replace("%", "%%"))
target_metadata = current_app.extensions["migrate"].db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, "autogenerate", False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info("No changes in schema detected.")
connectable = current_app.extensions["migrate"].db.get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
process_revision_directives=process_revision_directives,
**current_app.extensions["migrate"].configure_args,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

25
migrations/script.py.mako Normal file
View File

@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@ -0,0 +1,141 @@
"""Initial migration.
Revision ID: d3026757c7cb
Revises:
Create Date: 2021-12-19 20:34:34.122576
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "d3026757c7cb"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"image",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("filename_", sa.String(length=127), nullable=False),
sa.Column("mimetype_", sa.String(length=30), nullable=False),
sa.Column("thumbnail_", sa.String(length=127), nullable=True),
sa.Column("path_", sa.String(length=127), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_image")),
)
op.create_table(
"permission",
sa.Column("name", sa.String(length=30), nullable=True),
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_permission")),
sa.UniqueConstraint("name", name=op.f("uq_permission_name")),
)
op.create_table(
"plugin_setting",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("plugin", sa.String(length=30), nullable=True),
sa.Column("name", sa.String(length=30), nullable=False),
sa.Column("value", sa.PickleType(), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_plugin_setting")),
)
op.create_table(
"role",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role")),
sa.UniqueConstraint("name", name=op.f("uq_role_name")),
)
op.create_table(
"role_x_permission",
sa.Column("role_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("permission_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(
["permission_id"], ["permission.id"], name=op.f("fk_role_x_permission_permission_id_permission")
),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_role_x_permission_role_id_role")),
)
op.create_table(
"user",
sa.Column("userid", sa.String(length=30), nullable=False),
sa.Column("display_name", sa.String(length=30), nullable=True),
sa.Column("firstname", sa.String(length=50), nullable=False),
sa.Column("lastname", sa.String(length=50), nullable=False),
sa.Column("deleted", sa.Boolean(), nullable=True),
sa.Column("birthday", sa.Date(), nullable=True),
sa.Column("mail", sa.String(length=60), nullable=True),
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("avatar", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["avatar"], ["image.id"], name=op.f("fk_user_avatar_image")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user")),
sa.UniqueConstraint("userid", name=op.f("uq_user_userid")),
)
op.create_table(
"notification",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("plugin", sa.String(length=127), nullable=False),
sa.Column("text", sa.Text(), nullable=True),
sa.Column("data", sa.PickleType(), nullable=True),
sa.Column("time", flaschengeist.models.UtcDateTime(), nullable=False),
sa.Column("user_id", flaschengeist.models.Serial(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_notification_user_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_notification")),
)
op.create_table(
"password_reset",
sa.Column("user", flaschengeist.models.Serial(), nullable=False),
sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_password_reset_user_user")),
sa.PrimaryKeyConstraint("user", name=op.f("pk_password_reset")),
)
op.create_table(
"session",
sa.Column("expires", flaschengeist.models.UtcDateTime(), nullable=True),
sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column("browser", sa.String(length=30), nullable=True),
sa.Column("platform", sa.String(length=30), nullable=True),
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_session_user_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_session")),
sa.UniqueConstraint("token", name=op.f("uq_session_token")),
)
op.create_table(
"user_attribute",
sa.Column("id", flaschengeist.models.Serial(), nullable=False),
sa.Column("user", flaschengeist.models.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=True),
sa.Column("value", sa.PickleType(), nullable=True),
sa.ForeignKeyConstraint(["user"], ["user.id"], name=op.f("fk_user_attribute_user_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_attribute")),
)
op.create_table(
"user_x_role",
sa.Column("user_id", flaschengeist.models.Serial(), nullable=True),
sa.Column("role_id", flaschengeist.models.Serial(), nullable=True),
sa.ForeignKeyConstraint(["role_id"], ["role.id"], name=op.f("fk_user_x_role_role_id_role")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_user_x_role_user_id_user")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("user_x_role")
op.drop_table("user_attribute")
op.drop_table("session")
op.drop_table("password_reset")
op.drop_table("notification")
op.drop_table("user")
op.drop_table("role_x_permission")
op.drop_table("role")
op.drop_table("plugin_setting")
op.drop_table("permission")
op.drop_table("image")
# ### end Alembic commands ###

View File

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