Compare commits

..

No commits in common. "main" and "feature/balance" have entirely different histories.

33 changed files with 157 additions and 474 deletions

1
.gitignore vendored
View File

@ -122,7 +122,6 @@ dmypy.json
*.swo *.swo
.vscode/ .vscode/
*.log *.log
.fleet/
data/ data/

View File

@ -1,12 +1,9 @@
import enum import enum
import json
from flask import Flask from flask import Flask
from flask_cors import CORS from flask_cors import CORS
from datetime import datetime, date from datetime import datetime, date
from flask.json import jsonify from flask.json import JSONEncoder, jsonify
from json import JSONEncoder
from flask.json.provider import JSONProvider
from sqlalchemy.exc import OperationalError from sqlalchemy.exc import OperationalError
from werkzeug.exceptions import HTTPException from werkzeug.exceptions import HTTPException
@ -15,8 +12,6 @@ from flaschengeist.controller import pluginController
from flaschengeist.utils.hook import Hook from flaschengeist.utils.hook import Hook
from flaschengeist.config import configure_app from flaschengeist.config import configure_app
from flaschengeist.database import db
class CustomJSONEncoder(JSONEncoder): class CustomJSONEncoder(JSONEncoder):
def default(self, o): def default(self, o):
@ -39,19 +34,6 @@ class CustomJSONEncoder(JSONEncoder):
return JSONEncoder.default(self, o) return JSONEncoder.default(self, o)
class CustomJSONProvider(JSONProvider):
ensure_ascii: bool = True
sort_keys: bool = True
def dumps(self, obj, **kwargs):
kwargs.setdefault("ensure_ascii", self.ensure_ascii)
kwargs.setdefault("sort_keys", self.sort_keys)
return json.dumps(obj, **kwargs, cls=CustomJSONEncoder)
def loads(self, s: str | bytes, **kwargs):
return json.loads(s, **kwargs)
@Hook("plugins.loaded") @Hook("plugins.loaded")
def load_plugins(app: Flask): def load_plugins(app: Flask):
app.config["FG_PLUGINS"] = {} app.config["FG_PLUGINS"] = {}
@ -61,9 +43,7 @@ def load_plugins(app: Flask):
try: try:
# Load class # Load class
cls = plugin.entry_point.load() cls = plugin.entry_point.load()
# plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin plugin = cls.query.get(plugin.id) if plugin.id is not None else plugin
# plugin = db.session.query(cls).get(plugin.id) if plugin.id is not None else plugin
plugin = db.session.get(cls, plugin.id) if plugin.id is not None else plugin
# Custom loading tasks # Custom loading tasks
plugin.load() plugin.load()
# Register blueprint # Register blueprint
@ -78,11 +58,9 @@ def load_plugins(app: Flask):
logger.info(f"Loaded plugin: {plugin.name}") logger.info(f"Loaded plugin: {plugin.name}")
app.config["FG_PLUGINS"][plugin.name] = plugin app.config["FG_PLUGINS"][plugin.name] = plugin
def create_app(test_config=None, cli=False): def create_app(test_config=None, cli=False):
app = Flask("flaschengeist") app = Flask("flaschengeist")
app.json_provider_class = CustomJSONProvider app.json_encoder = CustomJSONEncoder
app.json = CustomJSONProvider(app)
CORS(app) CORS(app)
with app.app_context(): with app.app_context():

View File

@ -37,6 +37,7 @@ class InterfaceGenerator:
if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef): if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef):
return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__ return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__
if origin is typing.Union: if origin is typing.Union:
if len(arguments) == 2 and arguments[1] is type(None): if len(arguments) == 2 and arguments[1] is type(None):
return "?", self.pytype(arguments[0])[1] return "?", self.pytype(arguments[0])[1]
else: else:
@ -80,6 +81,7 @@ class InterfaceGenerator:
d = {} d = {}
for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items(): for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items():
if not param.startswith("_") and not param.endswith("_"): if not param.startswith("_") and not param.endswith("_"):
d[param] = self.pytype(ptype) d[param] = self.pytype(ptype)
if len(d) == 1: if len(d) == 1:
@ -113,7 +115,7 @@ class InterfaceGenerator:
return buffer return buffer
def write(self): def write(self):
with open(self.filename, "w") if self.filename else sys.stdout as file: with (open(self.filename, "w") if self.filename else sys.stdout) as file:
if self.namespace: if self.namespace:
file.write(f"declare namespace {self.namespace} {{\n") file.write(f"declare namespace {self.namespace} {{\n")
for line in self._write_types().getvalue().split("\n"): for line in self._write_types().getvalue().split("\n"):

View File

@ -89,7 +89,6 @@ def main(*args, **kwargs):
from .docs_cmd import docs from .docs_cmd import docs
from .run_cmd import run from .run_cmd import run
from .install_cmd import install from .install_cmd import install
from .docker_cmd import docker
# Override logging level # Override logging level
environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX)) environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX))
@ -99,5 +98,4 @@ def main(*args, **kwargs):
cli.add_command(install) cli.add_command(install)
cli.add_command(plugin) cli.add_command(plugin)
cli.add_command(run) cli.add_command(run)
cli.add_command(docker)
cli(*args, **kwargs) cli(*args, **kwargs)

View File

@ -1,54 +0,0 @@
import click
from click.decorators import pass_context
from flask.cli import with_appcontext
from os import environ
from flaschengeist import logger
from flaschengeist.controller import pluginController
from werkzeug.exceptions import NotFound
import traceback
@click.group()
def docker():
pass
@docker.command()
@with_appcontext
@pass_context
def setup(ctx):
"""Setup flaschengesit in docker container"""
click.echo("Setup docker")
plugins = environ.get("FG_ENABLE_PLUGINS")
if not plugins:
click.secho("no evironment variable is set for 'FG_ENABLE_PLUGINS'", fg="yellow")
click.secho("set 'FG_ENABLE_PLUGINS' to 'auth_ldap', 'mail', 'balance', 'pricelist_old', 'events'")
plugins = ("auth_ldap", "mail", "pricelist_old", "events", "balance")
else:
plugins = plugins.split(" ")
print(plugins)
for name in plugins:
click.echo(f"Installing {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.install_plugin(name)
except Exception as e:
click.secho(" failed", fg="red")
if logger.getEffectiveLevel() > 10:
ctx.fail(f"[{e.__class__.__name__}] {e}")
else:
ctx.fail(traceback.format_exc())
else:
click.secho(" ok", fg="green")
for name in plugins:
click.echo(f"Enabling {name}{'.'*(20-len(name))}", nl=False)
try:
pluginController.enable_plugin(name)
click.secho(" ok", fg="green")
except NotFound:
click.secho(" not installed / not found", fg="red")

View File

@ -9,7 +9,7 @@ from importlib.metadata import entry_points
@click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True) @click.option("--no-core", help="Skip models / types from flaschengeist core", is_flag=True)
def export(namespace, output, no_core, plugin): def export(namespace, output, no_core, plugin):
from flaschengeist import logger, models from flaschengeist import logger, models
from flaschengeist.cli.InterfaceGenerator import InterfaceGenerator from .InterfaceGenerator import InterfaceGenerator
gen = InterfaceGenerator(namespace, output, logger) gen = InterfaceGenerator(namespace, output, logger)
if not no_core: if not no_core:

View File

@ -53,6 +53,7 @@ def disable(ctx, plugin):
def install(ctx: click.Context, plugin, all): def install(ctx: click.Context, plugin, all):
"""Install one or more plugins""" """Install one or more plugins"""
all_plugins = entry_points(group="flaschengeist.plugins") all_plugins = entry_points(group="flaschengeist.plugins")
if all: if all:
plugins = [ep.name for ep in all_plugins] plugins = [ep.name for ep in all_plugins]
elif len(plugin) > 0: elif len(plugin) > 0:

View File

@ -10,6 +10,7 @@ class PrefixMiddleware(object):
self.prefix = prefix self.prefix = prefix
def __call__(self, environ, start_response): def __call__(self, environ, start_response):
if environ["PATH_INFO"].startswith(self.prefix): if environ["PATH_INFO"].startswith(self.prefix):
environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :] environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :]
environ["SCRIPT_NAME"] = self.prefix environ["SCRIPT_NAME"] = self.prefix

View File

@ -28,7 +28,7 @@ def read_configuration(test_config):
if not test_config: if not test_config:
paths.append(Path.home() / ".config") paths.append(Path.home() / ".config")
if "FLASCHENGEIST_CONF" in os.environ: if "FLASCHENGEIST_CONF" in os.environ:
paths.append(Path(str(os.environ.get("FLASCHENGEIST_CONF")))) paths.append(Path(os.environ.get("FLASCHENGEIST_CONF")))
for loc in paths: for loc in paths:
try: try:

View File

@ -3,7 +3,7 @@
Used by plugins for setting and notification functionality. Used by plugins for setting and notification functionality.
""" """
from typing import Union, List from typing import Union
from flask import current_app from flask import current_app
from werkzeug.exceptions import NotFound, BadRequest from werkzeug.exceptions import NotFound, BadRequest
from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.exc import OperationalError, ProgrammingError
@ -23,11 +23,7 @@ __required_plugins = ["users", "roles", "scheduler", "auth"]
def get_authentication_provider(): def get_authentication_provider():
return [ return [current_app.config["FG_PLUGINS"][plugin.name] for plugin in get_loaded_plugins().values() if isinstance(plugin, AuthPlugin)]
current_app.config["FG_PLUGINS"][plugin.name]
for plugin in get_loaded_plugins().values()
if isinstance(plugin, AuthPlugin)
]
def get_loaded_plugins(plugin_name: str = None): def get_loaded_plugins(plugin_name: str = None):
@ -60,7 +56,7 @@ def get_enabled_plugins() -> list[Plugin]:
return enabled_plugins return enabled_plugins
def notify(plugin_id: int, user, text: str, data=None): def notify(plugin_id: str, user, text: str, data=None):
"""Create a new notification for an user """Create a new notification for an user
Args: Args:
@ -74,23 +70,12 @@ def notify(plugin_id: int, user, text: str, data=None):
Hint: use the data for frontend actions. Hint: use the data for frontend actions.
""" """
if not user.deleted: if not user.deleted:
n = Notification(text=text, data=data, plugin_id_=plugin_id, user_=user) n = Notification(text=text, data=data, plugin=plugin_id, user_=user)
db.session.add(n) db.session.add(n)
db.session.commit() db.session.commit()
return n.id return n.id
def get_notifications(plugin_id) -> List[Notification]:
"""Get all notifications for a plugin
Args:
plugin_id: ID of the plugin
Returns:
List of `flaschengeist.models.notification.Notification`
"""
return db.session.execute(db.select(Notification).where(Notification.plugin_id_ == plugin_id)).scalars().all()
@Hook("plugins.installed") @Hook("plugins.installed")
def install_plugin(plugin_name: str): def install_plugin(plugin_name: str):
logger.debug(f"Installing plugin {plugin_name}") logger.debug(f"Installing plugin {plugin_name}")
@ -108,14 +93,11 @@ def install_plugin(plugin_name: str):
plugin.install() plugin.install()
# Check migrations # Check migrations
directory = entry_point[0].dist.locate_file("") directory = entry_point[0].dist.locate_file("")
logger.debug(f"Checking for migrations in {directory}")
for loc in entry_point[0].module.split(".") + ["migrations"]: for loc in entry_point[0].module.split(".") + ["migrations"]:
directory /= loc directory /= loc
logger.debug(f"Checking for migrations with loc in {directory}")
if directory.exists(): if directory.exists():
logger.debug(f"Found migrations in {directory}")
database_upgrade(revision=f"{plugin_name}@head") database_upgrade(revision=f"{plugin_name}@head")
db.session.commit()
return plugin return plugin

View File

@ -2,7 +2,6 @@ import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized from werkzeug.exceptions import Forbidden, Unauthorized
from ua_parser import user_agent_parser
from .. import logger from .. import logger
from ..models import Session from ..models import Session
@ -12,8 +11,33 @@ from ..database import db
lifetime = 1800 lifetime = 1800
def get_user_agent(request_headers): def __get_user_agent_platform(ua: str):
return user_agent_parser.Parse(request_headers.get("User-Agent", "") if request_headers else "") if "Win" in ua:
return "windows"
if "Mac" in ua:
return "macintosh"
if "Linux" in ua:
return "linux"
if "Android" in ua:
return "android"
if "like Mac" in ua:
return "ios"
return "unknown"
def __get_user_agent_browser(ua: str):
ua_str = ua.lower()
if "firefox" in ua_str or "fxios" in ua_str:
return "firefox"
if "safari" in ua_str:
return "safari"
if "opr/" in ua_str:
return "opera"
if "edg" in ua_str:
return "edge"
if "chrom" in ua_str or "crios" in ua_str:
return "chrome"
return "unknown"
def validate_token(token, request_headers, permission): def validate_token(token, request_headers, permission):
@ -36,9 +60,13 @@ def validate_token(token, request_headers, permission):
session = Session.query.filter_by(token=token).one_or_none() session = Session.query.filter_by(token=token).one_or_none()
if session: if session:
logger.debug("token found, check if expired or invalid user agent differs") logger.debug("token found, check if expired or invalid user agent differs")
user_agent = get_user_agent(request_headers)
platform = user_agent["os"]["family"] platform = request_headers.get("Sec-CH-UA-Platform", None) or __get_user_agent_platform(
browser = user_agent["user_agent"]["family"] request_headers.get("User-Agent", "")
)
browser = request_headers.get("Sec-CH-UA", None) or __get_user_agent_browser(
request_headers.get("User-Agent", "")
)
if session.expires >= datetime.now(timezone.utc) and ( if session.expires >= datetime.now(timezone.utc) and (
session.browser == browser and session.platform == platform session.browser == browser and session.platform == platform
@ -68,14 +96,14 @@ def create(user, request_headers=None) -> Session:
""" """
logger.debug("create access token") logger.debug("create access token")
token_str = secrets.token_hex(16) token_str = secrets.token_hex(16)
user_agent = get_user_agent(request_headers)
logger.debug(f"platform: {user_agent['os']['family']}, browser: {user_agent['user_agent']['family']}")
session = Session( session = Session(
token=token_str, token=token_str,
user_=user, user_=user,
lifetime=lifetime, lifetime=lifetime,
platform=user_agent["os"]["family"], platform=request_headers.get("Sec-CH-UA-Platform", None)
browser=user_agent["user_agent"]["family"], or __get_user_agent_platform(request_headers.get("User-Agent", "")),
browser=request_headers.get("Sec-CH-UA", None)
or __get_user_agent_browser(request_headers.get("User-Agent", "")),
) )
session.refresh() session.refresh()
db.session.add(session) db.session.add(session)

View File

@ -1,14 +1,10 @@
import re import re
import secrets import secrets
import hashlib
from io import BytesIO from io import BytesIO
from typing import Optional, Union
from flask import make_response
from flask.json import provider
from sqlalchemy import exc from sqlalchemy import exc
from sqlalchemy_utils import merge_references from sqlalchemy_utils import merge_references
from datetime import datetime, timedelta, timezone, date from datetime import datetime, timedelta, timezone
from flask.helpers import send_file from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden from werkzeug.exceptions import NotFound, BadRequest, Forbidden
@ -19,12 +15,7 @@ from ..models import Notification, User, Role
from ..models.user import _PasswordReset from ..models.user import _PasswordReset
from ..utils.hook import Hook from ..utils.hook import Hook
from ..utils.datetime import from_iso_format from ..utils.datetime import from_iso_format
from ..controller import ( from ..controller import imageController, messageController, pluginController, sessionController
imageController,
messageController,
pluginController,
sessionController,
)
from ..plugins import AuthPlugin from ..plugins import AuthPlugin
@ -50,17 +41,15 @@ def _generate_password_reset(user):
return reset return reset
def get_provider(userid: str) -> AuthPlugin: def get_provider(userid: str):
return [p for p in pluginController.get_authentication_provider() if p.user_exists(userid)][0] return [p for p in pluginController.get_authentication_provider() if p.user_exists(userid)][0]
@Hook @Hook
def update_user(user: User, backend: Optional[AuthPlugin] = None): def update_user(user: User, backend: AuthPlugin):
"""Update user data from backend """Update user data from backend
This is seperate function to provide a hook""" This is seperate function to provide a hook"""
if not backend:
backend = get_provider(user.userid)
backend.update_user(user) backend.update_user(user)
if not user.display_name: if not user.display_name:
user.display_name = "{} {}.".format(user.firstname, user.lastname[0]) user.display_name = "{} {}.".format(user.firstname, user.lastname[0])
@ -203,11 +192,7 @@ def delete_user(user: User):
deleted_user = get_user("__deleted_user__", True) deleted_user = get_user("__deleted_user__", True)
except NotFound: except NotFound:
deleted_user = User( deleted_user = User(
userid="__deleted_user__", userid="__deleted_user__", firstname="USER", lastname="DELETED", display_name="DELETED USER", deleted=True
firstname="USER",
lastname="DELETED",
display_name="DELETED USER",
deleted=True,
) )
db.session.add(user) db.session.add(user)
db.session.flush() db.session.flush()
@ -218,10 +203,7 @@ def delete_user(user: User):
db.session.delete(user) db.session.delete(user)
db.session.commit() db.session.commit()
except exc.IntegrityError: except exc.IntegrityError:
logger.error( logger.error("Delete of user failed, there might be ForeignKey contraits from disabled plugins", exec_info=True)
"Delete of user failed, there might be ForeignKey contraits from disabled plugins",
exec_info=True,
)
# Remove at least all personal data # Remove at least all personal data
user.userid = f"__deleted_user__{user.id_}" user.userid = f"__deleted_user__{user.id_}"
user.display_name = "DELETED USER" user.display_name = "DELETED USER"
@ -243,9 +225,6 @@ def register(data, passwd=None):
values = {key: value for key, value in data.items() if key in allowed_keys} values = {key: value for key, value in data.items() if key in allowed_keys}
roles = values.pop("roles", []) roles = values.pop("roles", [])
if "birthday" in data: if "birthday" in data:
if isinstance(data["birthday"], date):
values["birthday"] = data["birthday"]
else:
values["birthday"] = from_iso_format(data["birthday"]).date() values["birthday"] = from_iso_format(data["birthday"]).date()
if "mail" in data and not re.match(r"[^@]+@[^@]+\.[^@]+", data["mail"]): if "mail" in data and not re.match(r"[^@]+@[^@]+\.[^@]+", data["mail"]):
raise BadRequest("Invalid mail given") raise BadRequest("Invalid mail given")
@ -280,22 +259,14 @@ def register(data, passwd=None):
return user return user
def get_last_modified(user: User): def load_avatar(user: User):
"""Get the last modification date of the user"""
return get_provider(user.userid).get_last_modified(user)
def load_avatar(user: User, etag: Union[str, None] = None):
if user.avatar_ is not None: if user.avatar_ is not None:
return imageController.send_image(image=user.avatar_) return imageController.send_image(image=user.avatar_)
else: else:
provider = get_provider(user.userid) provider = get_provider(user.userid)
avatar = provider.get_avatar(user) avatar = provider.get_avatar(user)
new_etag = hashlib.md5(avatar.binary).hexdigest()
if new_etag == etag:
return make_response("", 304)
if len(avatar.binary) > 0: if len(avatar.binary) > 0:
return send_file(BytesIO(avatar.binary), avatar.mimetype, etag=new_etag) return send_file(BytesIO(avatar.binary), avatar.mimetype)
raise NotFound raise NotFound

View File

@ -6,8 +6,7 @@ from sqlalchemy import MetaData
from flaschengeist.alembic import alembic_script_path from flaschengeist.alembic import alembic_script_path
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.controller import pluginController
# from flaschengeist.controller import pluginController
# https://alembic.sqlalchemy.org/en/latest/naming.html # https://alembic.sqlalchemy.org/en/latest/naming.html
metadata = MetaData( metadata = MetaData(
@ -21,7 +20,7 @@ metadata = MetaData(
) )
db = SQLAlchemy(metadata=metadata, session_options={"expire_on_commit": False}) db = SQLAlchemy(metadata=metadata)
migrate = Migrate() migrate = Migrate()

View File

@ -16,16 +16,13 @@ class ModelSerializeMixin:
module = import_module("flaschengeist.models").__dict__ module = import_module("flaschengeist.models").__dict__
try: hint = typing.get_type_hints(self.__class__, globalns=module)[param]
hint = typing.get_type_hints(self.__class__, globalns=module, locals=locals())[param]
if ( if (
typing.get_origin(hint) is typing.Union typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2 and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None) and typing.get_args(hint)[1] is type(None)
): ):
return getattr(self, param) is None return getattr(self, param) is None
except:
pass
def serialize(self): def serialize(self):
"""Serialize class to dict """Serialize class to dict
@ -38,7 +35,7 @@ class ModelSerializeMixin:
if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param) if not param.startswith("_") and not param.endswith("_") and not self.__is_optional(param)
} }
if len(d) == 1: if len(d) == 1:
_, value = d.popitem() key, value = d.popitem()
return value return value
return d return d

View File

@ -8,7 +8,6 @@ from ..database.types import ModelSerializeMixin, Serial
class Image(db.Model, ModelSerializeMixin): class Image(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "image" __tablename__ = "image"
id: int = db.Column(Serial, primary_key=True) id: int = db.Column(Serial, primary_key=True)
filename_: str = db.Column("filename", db.String(255), nullable=False) filename_: str = db.Column("filename", db.String(255), nullable=False)

View File

@ -8,7 +8,6 @@ from ..database.types import Serial, UtcDateTime, ModelSerializeMixin
class Notification(db.Model, ModelSerializeMixin): class Notification(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "notification" __tablename__ = "notification"
id: int = db.Column("id", Serial, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
text: str = db.Column(db.Text) text: str = db.Column(db.Text)
@ -21,8 +20,7 @@ class Notification(db.Model, ModelSerializeMixin):
plugin_: Plugin = db.relationship( plugin_: Plugin = db.relationship(
"Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan") "Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan")
) )
plugin: str
@property @property
def plugin(self) -> str: def plugin(self):
return self.plugin_.name return self.plugin_.name

View File

@ -1,6 +1,6 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Any, List, Dict from typing import Any
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db from ..database import db
@ -8,7 +8,6 @@ from ..database.types import Serial
class PluginSetting(db.Model): class PluginSetting(db.Model):
__allow_unmapped__ = True
__tablename__ = "plugin_setting" __tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True) id = db.Column("id", Serial, primary_key=True)
plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
@ -17,7 +16,6 @@ class PluginSetting(db.Model):
class BasePlugin(db.Model): class BasePlugin(db.Model):
__allow_unmapped__ = True
__tablename__ = "plugin" __tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False) name: str = db.Column(db.String(127), nullable=False)
@ -26,7 +24,7 @@ class BasePlugin(db.Model):
"""The latest installed version""" """The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False) enabled: bool = db.Column(db.Boolean, default=False)
"""Enabled state of the plugin""" """Enabled state of the plugin"""
permissions: List["Permission"] = db.relationship( permissions: list = db.relationship(
"Permission", cascade="all, delete, delete-orphan", back_populates="plugin_", lazy="select" "Permission", cascade="all, delete, delete-orphan", back_populates="plugin_", lazy="select"
) )
"""Optional list of custom permissions used by the plugin """Optional list of custom permissions used by the plugin
@ -35,11 +33,11 @@ class BasePlugin(db.Model):
to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*. to prevent clashes with other plugins. E. g. instead of *delete* use *plugin_delete*.
""" """
__settings: Dict[str, "PluginSetting"] = db.relationship( __settings: dict[str, "PluginSetting"] = db.relationship(
"PluginSetting", "PluginSetting",
collection_class=attribute_mapped_collection("name"), collection_class=attribute_mapped_collection("name"),
cascade="all, delete, delete-orphan", cascade="all, delete, delete-orphan",
lazy="subquery", lazy="select",
) )
def get_setting(self, name: str, **kwargs): def get_setting(self, name: str, **kwargs):

View File

@ -17,7 +17,6 @@ class Session(db.Model, ModelSerializeMixin):
token: String to verify access later. token: String to verify access later.
""" """
__allow_unmapped__ = True
__tablename__ = "session" __tablename__ = "session"
expires: datetime = db.Column(UtcDateTime) expires: datetime = db.Column(UtcDateTime)
token: str = db.Column(db.String(32), unique=True) token: str = db.Column(db.String(32), unique=True)

View File

@ -1,8 +1,6 @@
from __future__ import ( from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
annotations,
) # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Optional, Union, List from typing import Optional
from datetime import date, datetime from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
@ -23,21 +21,19 @@ role_permission_association_table = db.Table(
class Permission(db.Model, ModelSerializeMixin): class Permission(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "permission" __tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True) name: str = db.Column(db.String(30), unique=True)
id_ = db.Column("id", Serial, primary_key=True) id_ = db.Column("id", Serial, primary_key=True)
plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id")) plugin_id_: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
plugin_ = db.relationship("Plugin", lazy="subquery", back_populates="permissions", enable_typechecks=False) plugin_ = db.relationship("Plugin", lazy="select", back_populates="permissions", enable_typechecks=False)
class Role(db.Model, ModelSerializeMixin): class Role(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "role" __tablename__ = "role"
id: int = db.Column(Serial, primary_key=True) id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), unique=True) name: str = db.Column(db.String(30), unique=True)
permissions: List[Permission] = db.relationship("Permission", secondary=role_permission_association_table) permissions: list[Permission] = db.relationship("Permission", secondary=role_permission_association_table)
class User(db.Model, ModelSerializeMixin): class User(db.Model, ModelSerializeMixin):
@ -47,7 +43,7 @@ class User(db.Model, ModelSerializeMixin):
Attributes: Attributes:
id: Id in Database as Primary Key. id: Id in Database as Primary Key.
userid: User ID used by authentication provider uid: User ID used by authentication provider
display_name: Name to show display_name: Name to show
firstname: Firstname of the User firstname: Firstname of the User
lastname: Lastname of the User lastname: Lastname of the User
@ -55,7 +51,6 @@ class User(db.Model, ModelSerializeMixin):
birthday: Birthday of the user birthday: Birthday of the user
""" """
__allow_unmapped__ = True
__tablename__ = "user" __tablename__ = "user"
userid: str = db.Column(db.String(30), unique=True, nullable=False) userid: str = db.Column(db.String(30), unique=True, nullable=False)
display_name: str = db.Column(db.String(30)) display_name: str = db.Column(db.String(30))
@ -64,15 +59,15 @@ class User(db.Model, ModelSerializeMixin):
deleted: bool = db.Column(db.Boolean(), default=False) deleted: bool = db.Column(db.Boolean(), default=False)
birthday: Optional[date] = db.Column(db.Date) birthday: Optional[date] = db.Column(db.Date)
mail: str = db.Column(db.String(60)) mail: str = db.Column(db.String(60))
roles: List[str] = [] roles: list[str] = []
permissions: Optional[list[str]] = [] permissions: Optional[list[str]] = None
# Protected stuff for backend use only # Protected stuff for backend use only
id_ = db.Column("id", Serial, primary_key=True) id_ = db.Column("id", Serial, primary_key=True)
roles_: List[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge") roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge")
sessions_: List[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan") sessions_: list[Session] = db.relationship("Session", back_populates="user_", cascade="all, delete, delete-orphan")
avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True) avatar_: Optional[Image] = db.relationship("Image", cascade="all, delete, delete-orphan", single_parent=True)
reset_requests_: List["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan") reset_requests_: list["_PasswordReset"] = db.relationship("_PasswordReset", cascade="all, delete, delete-orphan")
# Private stuff for internal use # Private stuff for internal use
_avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id")) _avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id"))
@ -83,7 +78,7 @@ class User(db.Model, ModelSerializeMixin):
) )
@property @property
def roles(self) -> List[str]: def roles(self):
return [role.name for role in self.roles_] return [role.name for role in self.roles_]
def set_attribute(self, name, value): def set_attribute(self, name, value):
@ -112,7 +107,6 @@ class User(db.Model, ModelSerializeMixin):
class _UserAttribute(db.Model, ModelSerializeMixin): class _UserAttribute(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "user_attribute" __tablename__ = "user_attribute"
id = db.Column("id", Serial, primary_key=True) id = db.Column("id", Serial, primary_key=True)
user: User = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False) user: User = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False)
@ -123,7 +117,6 @@ class _UserAttribute(db.Model, ModelSerializeMixin):
class _PasswordReset(db.Model): class _PasswordReset(db.Model):
"""Table containing password reset requests""" """Table containing password reset requests"""
__allow_unmapped__ = True
__tablename__ = "password_reset" __tablename__ = "password_reset"
_user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True) _user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True)
user: User = db.relationship("User", back_populates="reset_requests_", foreign_keys=[_user_id]) user: User = db.relationship("User", back_populates="reset_requests_", foreign_keys=[_user_id])

View File

@ -4,7 +4,7 @@
""" """
from typing import Union, List from typing import Union
from importlib.metadata import entry_points from importlib.metadata import entry_points
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
@ -100,7 +100,7 @@ class Plugin(BasePlugin):
@property @property
def entry_point(self): def entry_point(self):
ep = tuple(entry_points(group="flaschengeist.plugins", name=self.name)) ep = entry_points(group="flaschengeist.plugins", name=self.name)
return ep[0] return ep[0]
def load(self): def load(self):
@ -142,18 +142,7 @@ class Plugin(BasePlugin):
""" """
from ..controller import pluginController from ..controller import pluginController
return pluginController.notify(self.id, user, text, data) return pluginController.notify(self.name, user, text, data)
@property
def notifications(self) -> List["Notification"]:
"""Get all notifications for this plugin
Returns:
List of `flaschengeist.models.notification.Notification`
"""
from ..controller import pluginController
return pluginController.get_notifications(self.id)
def serialize(self): def serialize(self):
"""Serialize a plugin into a dict """Serialize a plugin into a dict
@ -169,13 +158,13 @@ class Plugin(BasePlugin):
Args: Args:
permissions: List of permissions to install permissions: List of permissions to install
""" """
cur_perm = set(x for x in self.permissions or []) cur_perm = set(x.name for x in self.permissions)
all_perm = set(permissions) all_perm = set(permissions)
new_perms = all_perm - cur_perm new_perms = all_perm - cur_perm
_perms = [Permission(name=x, plugin_=self) for x in new_perms] self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [
# self.permissions = list(filter(lambda x: x.name in permissions, self.permissions and isinstance(self.permissions, list) or [])) Permission(name=x, plugin_=self) for x in new_perms
self.permissions.extend(_perms) ]
class AuthPlugin(Plugin): class AuthPlugin(Plugin):
@ -248,16 +237,6 @@ class AuthPlugin(Plugin):
""" """
raise NotImplementedError raise NotImplementedError
def get_modified_time(self, user):
"""If backend is using external data, then return the timestamp of the last modification
Args:
user: User object
Returns:
Timestamp of last modification
"""
pass
def get_avatar(self, user) -> _Avatar: def get_avatar(self, user) -> _Avatar:
"""Retrieve avatar for given user (if supported by auth backend) """Retrieve avatar for given user (if supported by auth backend)

View File

@ -165,7 +165,7 @@ def get_assocd_user(token, current_session, **kwargs):
def reset_password(): def reset_password():
data = request.get_json() data = request.get_json()
if "userid" in data: if "userid" in data:
user = userController.get_user(data["userid"]) user = userController.find_user(data["userid"])
if user: if user:
userController.request_reset(user) userController.request_reset(user)
elif "password" in data and "token" in data: elif "password" in data and "token" in data:

View File

@ -10,8 +10,6 @@ from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
from datetime import datetime
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.controller import userController from flaschengeist.controller import userController
@ -128,12 +126,9 @@ class AuthLDAP(AuthPlugin):
def modify_user(self, user: User, password=None, new_password=None): def modify_user(self, user: User, password=None, new_password=None):
try: try:
dn = user.get_attribute("DN") dn = user.get_attribute("DN")
logger.debug(f"LDAP: modify_user for user {user.userid} with dn {dn}")
if password: if password:
logger.debug(f"LDAP: modify_user for user {user.userid} with password")
ldap_conn = self.ldap.connect(dn, password) ldap_conn = self.ldap.connect(dn, password)
else: else:
logger.debug(f"LDAP: modify_user for user {user.userid} with root_dn")
if self.root_dn is None: if self.root_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("root_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
@ -146,31 +141,14 @@ class AuthLDAP(AuthPlugin):
("display_name", "displayName"), ("display_name", "displayName"),
]: ]:
if hasattr(user, name): if hasattr(user, name):
attribute = getattr(user, name)
if attribute:
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])] modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
if new_password: if new_password:
modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])] modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
if "userPassword" in modifier:
logger.debug(f"LDAP: modify_user for user {user.userid} with password change (can't show >modifier<)")
else:
logger.debug(f"LDAP: modify_user for user {user.userid} with modifier {modifier}")
ldap_conn.modify(dn, modifier) ldap_conn.modify(dn, modifier)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
def get_modified_time(self, user):
self.ldap.connection.search(
self.search_dn,
"(uid={})".format(user.userid),
SUBTREE,
attributes=["modifyTimestamp"],
)
r = self.ldap.connection.response[0]["attributes"]
modified_time = r["modifyTimestamp"][0]
return datetime.strptime(modified_time, "%Y%m%d%H%M%SZ")
def get_avatar(self, user): def get_avatar(self, user):
self.ldap.connection.search( self.ldap.connection.search(
self.search_dn, self.search_dn,

View File

@ -6,15 +6,13 @@ from werkzeug.exceptions import NotFound
@click.command(no_args_is_help=True) @click.command(no_args_is_help=True)
@click.option("--sync", is_flag=True, default=False, help="Synchronize users from LDAP -> database") @click.option("--sync", is_flag=True, default=False, help="Synchronize users from LDAP -> database")
@click.option("--sync-ldap", is_flag=True, default=False, help="Synchronize users from database -> LDAP")
@with_appcontext @with_appcontext
@click.pass_context @click.pass_context
def ldap(ctx, sync, sync_ldap): def ldap(ctx, sync):
"""Tools for the LDAP authentification""" """Tools for the LDAP authentification"""
if sync:
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP from flaschengeist.plugins.auth_ldap import AuthLDAP
if sync:
click.echo("Synchronizing users from LDAP -> database")
from ldap3 import SUBTREE from ldap3 import SUBTREE
from flaschengeist.models import User from flaschengeist.models import User
from flaschengeist.database import db from flaschengeist.database import db
@ -35,13 +33,3 @@ def ldap(ctx, sync, sync_ldap):
user = User(userid=uid) user = User(userid=uid)
db.session.add(user) db.session.add(user)
userController.update_user(user, auth_ldap) userController.update_user(user, auth_ldap)
if sync_ldap:
click.echo("Synchronizing users from database -> LDAP")
auth_ldap: AuthLDAP = current_app.config.get("FG_PLUGINS").get("auth_ldap")
if auth_ldap is None or not isinstance(auth_ldap, AuthLDAP):
ctx.fail("auth_ldap plugin not found or not enabled!")
users = userController.get_users()
for user in users:
userController.update_user(user, auth_ldap)

View File

@ -5,7 +5,6 @@ Extends users plugin with balance functions
from flask import current_app from flask import current_app
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from werkzeug.local import LocalProxy
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
@ -57,7 +56,6 @@ def service_debit():
class BalancePlugin(Plugin): class BalancePlugin(Plugin):
# id = "dev.flaschengeist.balance"
models = models models = models
def install(self): def install(self):
@ -65,7 +63,6 @@ class BalancePlugin(Plugin):
def load(self): def load(self):
from .routes import blueprint from .routes import blueprint
self.blueprint = blueprint self.blueprint = blueprint
@plugins_loaded @plugins_loaded
@ -74,7 +71,7 @@ class BalancePlugin(Plugin):
add_scheduled(f"{id}.service_debit", service_debit, minutes=1) add_scheduled(f"{id}.service_debit", service_debit, minutes=1)
@before_update_user @before_update_user
def set_default_limit(user, *args): def set_default_limit(user):
from . import balance_controller from . import balance_controller
try: try:
@ -83,7 +80,3 @@ class BalancePlugin(Plugin):
balance_controller.set_limit(user, limit, override=False) balance_controller.set_limit(user, limit, override=False)
except KeyError: except KeyError:
pass pass
@staticmethod
def getPlugin() -> LocalProxy["BalancePlugin"]:
return LocalProxy(lambda: current_app.config["FG_PLUGINS"]["balance"])

View File

@ -3,14 +3,13 @@
# English: Debit -> from account # English: Debit -> from account
# Credit -> to account # Credit -> to account
from enum import IntEnum from enum import IntEnum
from sqlalchemy import func, case, and_, or_ from sqlalchemy import func, case, and_
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from datetime import datetime from datetime import datetime
from werkzeug.exceptions import BadRequest, NotFound, Conflict from werkzeug.exceptions import BadRequest, NotFound, Conflict
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.user import User, _UserAttribute from flaschengeist.models.user import User, _UserAttribute
from flaschengeist.app import logger
from .models import Transaction from .models import Transaction
from . import permissions, BalancePlugin from . import permissions, BalancePlugin
@ -21,8 +20,6 @@ __attribute_limit = "balance_limit"
class NotifyType(IntEnum): class NotifyType(IntEnum):
SEND_TO = 0x01 SEND_TO = 0x01
SEND_FROM = 0x02 SEND_FROM = 0x02
ADD_FROM = 0x03
SUB_FROM = 0x04
def set_limit(user: User, limit: float, override=True): def set_limit(user: User, limit: float, override=True):
@ -36,7 +33,7 @@ def get_limit(user: User) -> float:
def get_balance(user, start: datetime = None, end: datetime = None): def get_balance(user, start: datetime = None, end: datetime = None):
query = db.session.query(func.sum(Transaction._amount)) query = db.session.query(func.sum(Transaction.amount))
if start: if start:
query = query.filter(start <= Transaction.time) query = query.filter(start <= Transaction.time)
if end: if end:
@ -47,26 +44,10 @@ def get_balance(user, start: datetime = None, end: datetime = None):
return credit, debit, credit - debit return credit, debit, credit - debit
def get_balances( def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None):
start: datetime = None,
end: datetime = None,
limit=None,
offset=None,
descending=None,
sortBy=None,
_filter=None,
):
logger.debug(
f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})"
)
class _User(User): class _User(User):
_debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id]) _debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id])
_credit = db.relationship( _credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id])
Transaction,
back_populates="receiver_",
foreign_keys=[Transaction._receiver_id],
)
@hybrid_property @hybrid_property
def debit(self): def debit(self):
@ -75,8 +56,8 @@ def get_balances(
@debit.expression @debit.expression
def debit(cls): def debit(cls):
a = ( a = (
db.select(func.sum(Transaction._amount)) db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._sender_id, Transaction._amount) .where(cls.id_ == Transaction._sender_id, Transaction.amount)
.scalar_subquery() .scalar_subquery()
) )
return case([(a, a)], else_=0) return case([(a, a)], else_=0)
@ -88,8 +69,8 @@ def get_balances(
@credit.expression @credit.expression
def credit(cls): def credit(cls):
b = ( b = (
db.select(func.sum(Transaction._amount)) db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._receiver_id, Transaction._amount) .where(cls.id_ == Transaction._receiver_id, Transaction.amount)
.scalar_subquery() .scalar_subquery()
) )
return case([(b, b)], else_=0) return case([(b, b)], else_=0)
@ -102,12 +83,7 @@ def get_balances(
def limit(cls): def limit(cls):
return ( return (
db.select(_UserAttribute.value) db.select(_UserAttribute.value)
.where( .where(and_(cls.id_ == _UserAttribute.user, _UserAttribute.name == "balance_limit"))
and_(
cls.id_ == _UserAttribute.user,
_UserAttribute.name == "balance_limit",
)
)
.scalar_subquery() .scalar_subquery()
) )
@ -140,27 +116,11 @@ def get_balances(
q2 = query.join(_User._debit).filter(Transaction.time <= end) q2 = query.join(_User._debit).filter(Transaction.time <= end)
query = q1.union(q2) query = q1.union(q2)
if _filter:
query = query.filter(
or_(
_User.firstname.ilike(f"%{_filter.lower()}%"),
_User.lastname.ilike(f"%{_filter.lower()}%"),
)
)
if sortBy == "balance": if sortBy == "balance":
if descending: if descending:
query = query.order_by( query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc())
(_User.credit - _User.debit).desc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
else: else:
query = query.order_by( query = query.order_by((_User.credit - _User.debit).asc(), _User.lastname.asc(), _User.firstname.asc())
(_User.credit - _User.debit).asc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
elif sortBy == "limit": elif sortBy == "limit":
if descending: if descending:
query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc()) query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc())
@ -187,6 +147,7 @@ def get_balances(
all = {} all = {}
for user in users: for user in users:
all[user.userid] = [user.get_credit(start, end), 0] all[user.userid] = [user.get_credit(start, end), 0]
all[user.userid][1] = user.get_debit(start, end) all[user.userid][1] = user.get_debit(start, end)
@ -206,7 +167,6 @@ def send(sender: User, receiver, amount: float, author: User):
Raises: Raises:
BadRequest if amount <= 0 BadRequest if amount <= 0
""" """
logger.debug(f"send(sender={sender}, receiver={receiver}, amount={amount}, author={author})")
if amount <= 0: if amount <= 0:
raise BadRequest raise BadRequest
@ -220,8 +180,7 @@ def send(sender: User, receiver, amount: float, author: User):
db.session.add(transaction) db.session.add(transaction)
db.session.commit() db.session.commit()
if sender is not None and sender.id_ != author.id_: if sender is not None and sender.id_ != author.id_:
if receiver is not None: BalancePlugin.plugin.notify(
BalancePlugin.getPlugin().notify(
sender, sender,
"Neue Transaktion", "Neue Transaktion",
{ {
@ -231,36 +190,9 @@ def send(sender: User, receiver, amount: float, author: User):
"amount": amount, "amount": amount,
}, },
) )
else:
BalancePlugin.getPlugin().notify(
sender,
"Neue Transaktion",
{
"type": NotifyType.SUB_FROM,
"author_id": author.userid,
"amount": amount,
},
)
if receiver is not None and receiver.id_ != author.id_: if receiver is not None and receiver.id_ != author.id_:
if sender is not None: BalancePlugin.plugin.notify(
BalancePlugin.getPlugin().notify( receiver, "Neue Transaktion", {"type": NotifyType.SEND_TO, "sender_id": sender.userid, "amount": amount}
receiver,
"Neue Transaktion",
{
"type": NotifyType.SEND_TO,
"sender_id": sender.userid,
"amount": amount,
},
)
else:
BalancePlugin.getPlugin().notify(
receiver,
"Neue Transaktion",
{
"type": NotifyType.ADD_FROM,
"author_id": author.userid,
"amount": amount,
},
) )
return transaction return transaction

View File

@ -1,16 +1,13 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.ext.hybrid import hybrid_property
from math import floor
from flaschengeist import logger
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.user import User from flaschengeist.models.user import User
from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
class Transaction(db.Model, ModelSerializeMixin): class Transaction(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "balance_transaction" __tablename__ = "balance_transaction"
# Protected foreign key properties # Protected foreign key properties
_receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id")) _receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id"))
@ -20,9 +17,8 @@ class Transaction(db.Model, ModelSerializeMixin):
# Public and exported member # Public and exported member
id: int = db.Column("id", Serial, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc) time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
_amount: float = db.Column("amount", db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id")) reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id"))
amount: float
# Dummy properties used for JSON serialization (userid instead of full user) # Dummy properties used for JSON serialization (userid instead of full user)
author_id: Optional[str] = None author_id: Optional[str] = None
@ -59,14 +55,3 @@ class Transaction(db.Model, ModelSerializeMixin):
@property @property
def original_id(self): def original_id(self):
return self.original_.id if self.original_ else None return self.original_.id if self.original_ else None
@property
def amount(self):
return self._amount
@amount.setter
def amount(self, value):
self._amount = floor(value * 100) / 100
def __repr__(self):
return f"<Transaction {self.id} {self.amount} {self.time} {self.sender_id} {self.receiver_id} {self.author_id}>"

View File

@ -1,5 +1,4 @@
from datetime import datetime, timezone from datetime import datetime, timezone
from logging import log
from werkzeug.exceptions import Forbidden, BadRequest from werkzeug.exceptions import Forbidden, BadRequest
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
@ -8,7 +7,6 @@ from flaschengeist.models.session import Session
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import login_required from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.app import logger
from . import BalancePlugin, balance_controller, permissions from . import BalancePlugin, balance_controller, permissions
@ -164,7 +162,6 @@ def get_balance(userid, current_session: Session):
end = datetime.now(tz=timezone.utc) end = datetime.now(tz=timezone.utc)
balance = balance_controller.get_balance(user, start, end) balance = balance_controller.get_balance(user, start, end)
logger.debug(f"Balance of {user.userid} from {start} to {end}: {balance}")
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@ -226,7 +223,6 @@ def get_transactions(userid, current_session: Session):
show_cancelled=show_cancelled, show_cancelled=show_cancelled,
descending=descending, descending=descending,
) )
logger.debug(f"transactions: {transactions}")
return {"transactions": transactions, "count": count} return {"transactions": transactions, "count": count}
@ -321,15 +317,7 @@ def get_balances(current_session: Session):
offset = request.args.get("offset", type=int) offset = request.args.get("offset", type=int)
descending = request.args.get("descending", False, type=bool) descending = request.args.get("descending", False, type=bool)
sortBy = request.args.get("sortBy", type=str) sortBy = request.args.get("sortBy", type=str)
_filter = request.args.get("filter", None, type=str) balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy)
logger.debug(f"request.args: {request.args}")
balances, count = balance_controller.get_balances(
limit=limit,
offset=offset,
descending=descending,
sortBy=sortBy,
_filter=_filter,
)
return jsonify( return jsonify(
{ {
"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()], "balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()],

View File

@ -1,7 +1,6 @@
import smtplib import smtplib
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
from werkzeug.exceptions import InternalServerError
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.models import User from flaschengeist.models import User
@ -9,29 +8,23 @@ from flaschengeist.plugins import Plugin
from flaschengeist.utils.hook import HookAfter from flaschengeist.utils.hook import HookAfter
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.controller.messageController import Message from flaschengeist.controller.messageController import Message
from flaschengeist.config import config
class MailMessagePlugin(Plugin): class MailMessagePlugin(Plugin):
def load(self): def __init__(self, entry_point, config):
self.config = config.get("mail", None) super().__init__(entry_point, config)
if self.config is None: self.server = config["SERVER"]
logger.error("mail was not configured in flaschengeist.toml") self.port = config["PORT"]
raise InternalServerError self.user = config["USER"]
self.server = self.config["SERVER"] self.password = config["PASSWORD"]
self.port = self.config["PORT"] self.crypt = config["CRYPT"]
self.user = self.config["USER"] self.mail = config["MAIL"]
self.password = self.config["PASSWORD"]
self.crypt = self.config["CRYPT"]
self.mail = self.config["MAIL"]
@HookAfter("send_message") @HookAfter("send_message")
def dummy_send(msg, *args, **kwargs): def dummy_send(msg):
logger.info(f"(dummy_send) Sending message to {msg.receiver}")
self.send_mail(msg) self.send_mail(msg)
def send_mail(self, msg: Message): def send_mail(self, msg: Message):
logger.debug(f"Sending mail to {msg.receiver} with subject {msg.subject}")
if isinstance(msg.receiver, User): if isinstance(msg.receiver, User):
if not msg.receiver.mail: if not msg.receiver.mail:
logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver)) logger.warning("Could not send Mail, mail missing: {}".format(msg.receiver))
@ -45,8 +38,9 @@ class MailMessagePlugin(Plugin):
mail["To"] = ", ".join(recipients) mail["To"] = ", ".join(recipients)
mail["Subject"] = msg.subject mail["Subject"] = msg.subject
mail.attach(MIMEText(msg.message)) mail.attach(MIMEText(msg.message))
with self.__connect() as smtp: if not hasattr(self, "smtp"):
smtp.sendmail(self.mail, recipients, mail.as_string()) self.__connect()
self.smtp.sendmail(self.mail, recipients, mail.as_string())
def __connect(self): def __connect(self):
if self.crypt == "SSL": if self.crypt == "SSL":
@ -57,4 +51,3 @@ class MailMessagePlugin(Plugin):
else: else:
raise ValueError("Invalid CRYPT given") raise ValueError("Invalid CRYPT given")
self.smtp.login(self.user, self.password) self.smtp.login(self.user, self.password)
return self.smtp

View File

@ -61,7 +61,6 @@ class SchedulerPlugin(Plugin):
def run_tasks(self): def run_tasks(self):
from ..database import db from ..database import db
self = db.session.merge(self) self = db.session.merge(self)
changed = False changed = False

View File

@ -2,23 +2,19 @@
Provides routes used to manage users Provides routes used to manage users
""" """
from datetime import datetime
from http.client import CREATED from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response
from flask import Blueprint, Response, after_this_request, jsonify, make_response, request
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.controller import userController
from flaschengeist.models import User
from flaschengeist.plugins import Plugin from flaschengeist.plugins import Plugin
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.models import User
from flaschengeist.utils.decorators import extract_session, headers, login_required from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created, no_content from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format
from . import permissions
class UsersPlugin(Plugin): class UsersPlugin(Plugin):
@ -61,7 +57,7 @@ def register():
@UsersPlugin.blueprint.route("/users", methods=["GET"]) @UsersPlugin.blueprint.route("/users", methods=["GET"])
@login_required() @login_required()
# @headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) @headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def list_users(current_session): def list_users(current_session):
"""List all existing users """List all existing users
@ -110,7 +106,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()
@ -122,13 +118,10 @@ def frontend(userid, current_session):
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["GET"]) @UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["GET"])
@headers({"Cache-Control": "public, must-revalidate, max-age=10"}) @headers({"Cache-Control": "public, max-age=604800"})
def get_avatar(userid): def get_avatar(userid):
etag = None
if "If-None-Match" in request.headers:
etag = request.headers["If-None-Match"]
user = userController.get_user(userid) user = userController.get_user(userid)
return userController.load_avatar(user, etag) return userController.load_avatar(user)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"]) @UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@ -225,9 +218,7 @@ def edit_user(userid, current_session):
userController.set_roles(user, roles) userController.set_roles(user, roles)
userController.modify_user(user, password, new_password) userController.modify_user(user, password, new_password)
userController.update_user( userController.update_user(user)
user,
)
return no_content() return no_content()
@ -263,21 +254,3 @@ def shortcuts(userid, current_session):
user.set_attribute("users_link_shortcuts", data) user.set_attribute("users_link_shortcuts", data)
userController.persist() userController.persist()
return no_content() return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/setting/<setting>", methods=["GET", "PUT"])
@login_required()
def settings(userid, setting, current_session):
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
retVal = user.get_attribute(setting, None)
logger.debug(f"Get setting >>{setting}<< for user >>{user.userid}<< with >>{retVal}<<")
return jsonify(retVal)
else:
data = request.get_json()
logger.debug(f"Set setting >>{setting}<< for user >>{user.userid}<< to >>{data}<<")
user.set_attribute(setting, data)
userController.persist()
return no_content()

View File

@ -3,7 +3,6 @@ import sqlalchemy.exc
from flask.cli import with_appcontext from flask.cli import with_appcontext
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from flaschengeist import logger
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.controller import roleController, userController from flaschengeist.controller import roleController, userController
@ -71,19 +70,12 @@ def user(add_role, delete, user):
if USER_KEY in ctx.meta: if USER_KEY in ctx.meta:
userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"]) userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"])
else: else:
if not isinstance(user, list) or not isinstance(user, tuple):
user = [user]
for uid in user: for uid in user:
logger.debug(f"Userid: {uid}")
user = userController.get_user(uid) user = userController.get_user(uid)
logger.debug(f"User: {user}")
if delete: if delete:
logger.debug(f"Deleting user {user}")
userController.delete_user(user) userController.delete_user(user)
elif add_role: elif add_role:
logger.debug(f"Adding role {add_role} to user {user}")
role = roleController.get(add_role) role = roleController.get(add_role)
logger.debug(f"Role: {role}")
user.roles_.append(role) user.roles_.append(role)
userController.modify_user(user, None) userController.modify_user(user, None)
db.session.commit() db.session.commit()

View File

@ -1,6 +1,3 @@
[build-system] [build-system]
requires = ["setuptools", "wheel"] requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.black]
line-length = 120

View File

@ -1,6 +1,6 @@
[metadata] [metadata]
license = MIT license = MIT
version = 2.1.0 version = 2.0.0.dev0
name = flaschengeist name = flaschengeist
author = Tim Gröger author = Tim Gröger
author_email = flaschengeist@wu5.de author_email = flaschengeist@wu5.de
@ -22,8 +22,7 @@ include_package_data = True
python_requires = >=3.10 python_requires = >=3.10
packages = find: packages = find:
install_requires = install_requires =
#Flask>=2.2.2, <2.3 Flask>=2.2.2
Flask>=2.2.2, <2.9
Pillow>=9.2 Pillow>=9.2
flask_cors flask_cors
flask_migrate>=3.1.0 flask_migrate>=3.1.0
@ -31,22 +30,20 @@ install_requires =
sqlalchemy_utils>=0.38.3 sqlalchemy_utils>=0.38.3
# Importlib requirement can be dropped when python requirement is >= 3.10 # Importlib requirement can be dropped when python requirement is >= 3.10
importlib_metadata>=4.3 importlib_metadata>=4.3
#sqlalchemy>=1.4.40, <2.0 sqlalchemy>=1.4.40, <2.0
sqlalchemy >= 2.0
toml toml
werkzeug>=2.2.2 werkzeug>=2.2.2
ua-parser>=0.16.1
[options.extras_require] [options.extras_require]
argon = argon2-cffi argon = argon2-cffi
ldap = flask_ldapconn @ git+https://github.com/rroemhild/flask-ldapconn.git; ldap3 ldap = flask_ldapconn; ldap3
tests = pytest; pytest-depends; coverage tests = pytest; pytest-depends; coverage
mysql = mysql =
PyMySQL;platform_system=='Windows' PyMySQL;platform_system=='Windows'
mysqlclient;platform_system!='Windows' mysqlclient;platform_system!='Windows'
[options.package_data] [options.package_data]
* = *.toml, script.py.mako, *.ini, */migrations/*, migrations/versions/* * = *.toml, script.py.mako
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =