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
.vscode/
*.log
.fleet/
data/

View File

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

View File

@ -37,6 +37,7 @@ class InterfaceGenerator:
if origin is typing.ForwardRef: # isinstance(cls, typing.ForwardRef):
return "", "this" if cls.__forward_arg__ == self.this_type else cls.__forward_arg__
if origin is typing.Union:
if len(arguments) == 2 and arguments[1] is type(None):
return "?", self.pytype(arguments[0])[1]
else:
@ -80,6 +81,7 @@ class InterfaceGenerator:
d = {}
for param, ptype in typing.get_type_hints(module[1], globalns=None, localns=None).items():
if not param.startswith("_") and not param.endswith("_"):
d[param] = self.pytype(ptype)
if len(d) == 1:
@ -113,7 +115,7 @@ class InterfaceGenerator:
return buffer
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:
file.write(f"declare namespace {self.namespace} {{\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 .run_cmd import run
from .install_cmd import install
from .docker_cmd import docker
# Override logging level
environ.setdefault("FG_LOGGING", logging.getLevelName(LOGGING_MAX))
@ -99,5 +98,4 @@ def main(*args, **kwargs):
cli.add_command(install)
cli.add_command(plugin)
cli.add_command(run)
cli.add_command(docker)
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)
def export(namespace, output, no_core, plugin):
from flaschengeist import logger, models
from flaschengeist.cli.InterfaceGenerator import InterfaceGenerator
from .InterfaceGenerator import InterfaceGenerator
gen = InterfaceGenerator(namespace, output, logger)
if not no_core:

View File

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

View File

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

View File

@ -28,7 +28,7 @@ def read_configuration(test_config):
if not test_config:
paths.append(Path.home() / ".config")
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:
try:

View File

@ -3,7 +3,7 @@
Used by plugins for setting and notification functionality.
"""
from typing import Union, List
from typing import Union
from flask import current_app
from werkzeug.exceptions import NotFound, BadRequest
from sqlalchemy.exc import OperationalError, ProgrammingError
@ -23,11 +23,7 @@ __required_plugins = ["users", "roles", "scheduler", "auth"]
def get_authentication_provider():
return [
current_app.config["FG_PLUGINS"][plugin.name]
for plugin in get_loaded_plugins().values()
if isinstance(plugin, AuthPlugin)
]
return [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):
@ -60,7 +56,7 @@ def get_enabled_plugins() -> list[Plugin]:
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
Args:
@ -74,23 +70,12 @@ def notify(plugin_id: int, user, text: str, data=None):
Hint: use the data for frontend actions.
"""
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.commit()
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")
def install_plugin(plugin_name: str):
logger.debug(f"Installing plugin {plugin_name}")
@ -108,14 +93,11 @@ def install_plugin(plugin_name: str):
plugin.install()
# Check migrations
directory = entry_point[0].dist.locate_file("")
logger.debug(f"Checking for migrations in {directory}")
for loc in entry_point[0].module.split(".") + ["migrations"]:
directory /= loc
logger.debug(f"Checking for migrations with loc in {directory}")
if directory.exists():
logger.debug(f"Found migrations in {directory}")
database_upgrade(revision=f"{plugin_name}@head")
db.session.commit()
return plugin

View File

@ -2,7 +2,6 @@ import secrets
from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized
from ua_parser import user_agent_parser
from .. import logger
from ..models import Session
@ -12,8 +11,33 @@ from ..database import db
lifetime = 1800
def get_user_agent(request_headers):
return user_agent_parser.Parse(request_headers.get("User-Agent", "") if request_headers else "")
def __get_user_agent_platform(ua: str):
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):
@ -36,9 +60,13 @@ def validate_token(token, request_headers, permission):
session = Session.query.filter_by(token=token).one_or_none()
if session:
logger.debug("token found, check if expired or invalid user agent differs")
user_agent = get_user_agent(request_headers)
platform = user_agent["os"]["family"]
browser = user_agent["user_agent"]["family"]
platform = request_headers.get("Sec-CH-UA-Platform", None) 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", "")
)
if session.expires >= datetime.now(timezone.utc) and (
session.browser == browser and session.platform == platform
@ -68,14 +96,14 @@ def create(user, request_headers=None) -> Session:
"""
logger.debug("create access token")
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(
token=token_str,
user_=user,
lifetime=lifetime,
platform=user_agent["os"]["family"],
browser=user_agent["user_agent"]["family"],
platform=request_headers.get("Sec-CH-UA-Platform", None)
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()
db.session.add(session)

View File

@ -1,14 +1,10 @@
import re
import secrets
import hashlib
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_utils import merge_references
from datetime import datetime, timedelta, timezone, date
from datetime import datetime, timedelta, timezone
from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden
@ -19,12 +15,7 @@ from ..models import Notification, User, Role
from ..models.user import _PasswordReset
from ..utils.hook import Hook
from ..utils.datetime import from_iso_format
from ..controller import (
imageController,
messageController,
pluginController,
sessionController,
)
from ..controller import imageController, messageController, pluginController, sessionController
from ..plugins import AuthPlugin
@ -50,17 +41,15 @@ def _generate_password_reset(user):
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]
@Hook
def update_user(user: User, backend: Optional[AuthPlugin] = None):
def update_user(user: User, backend: AuthPlugin):
"""Update user data from backend
This is seperate function to provide a hook"""
if not backend:
backend = get_provider(user.userid)
backend.update_user(user)
if not user.display_name:
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)
except NotFound:
deleted_user = User(
userid="__deleted_user__",
firstname="USER",
lastname="DELETED",
display_name="DELETED USER",
deleted=True,
userid="__deleted_user__", firstname="USER", lastname="DELETED", display_name="DELETED USER", deleted=True
)
db.session.add(user)
db.session.flush()
@ -218,10 +203,7 @@ def delete_user(user: User):
db.session.delete(user)
db.session.commit()
except exc.IntegrityError:
logger.error(
"Delete of user failed, there might be ForeignKey contraits from disabled plugins",
exec_info=True,
)
logger.error("Delete of user failed, there might be ForeignKey contraits from disabled plugins", exec_info=True)
# Remove at least all personal data
user.userid = f"__deleted_user__{user.id_}"
user.display_name = "DELETED USER"
@ -243,10 +225,7 @@ def register(data, passwd=None):
values = {key: value for key, value in data.items() if key in allowed_keys}
roles = values.pop("roles", [])
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"]):
raise BadRequest("Invalid mail given")
user = User(**values)
@ -280,22 +259,14 @@ def register(data, passwd=None):
return user
def get_last_modified(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):
def load_avatar(user: User):
if user.avatar_ is not None:
return imageController.send_image(image=user.avatar_)
else:
provider = get_provider(user.userid)
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:
return send_file(BytesIO(avatar.binary), avatar.mimetype, etag=new_etag)
return send_file(BytesIO(avatar.binary), avatar.mimetype)
raise NotFound

View File

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

View File

@ -16,16 +16,13 @@ class ModelSerializeMixin:
module = import_module("flaschengeist.models").__dict__
try:
hint = typing.get_type_hints(self.__class__, globalns=module, locals=locals())[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
except:
pass
hint = typing.get_type_hints(self.__class__, globalns=module)[param]
if (
typing.get_origin(hint) is typing.Union
and len(typing.get_args(hint)) == 2
and typing.get_args(hint)[1] is type(None)
):
return getattr(self, param) is None
def serialize(self):
"""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 len(d) == 1:
_, value = d.popitem()
key, value = d.popitem()
return value
return d

View File

@ -8,7 +8,6 @@ from ..database.types import ModelSerializeMixin, Serial
class Image(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "image"
id: int = db.Column(Serial, primary_key=True)
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):
__allow_unmapped__ = True
__tablename__ = "notification"
id: int = db.Column("id", Serial, primary_key=True)
text: str = db.Column(db.Text)
@ -21,8 +20,7 @@ class Notification(db.Model, ModelSerializeMixin):
plugin_: Plugin = db.relationship(
"Plugin", backref=db.backref("notifications_", cascade="all, delete, delete-orphan")
)
plugin: str
@property
def plugin(self) -> str:
def plugin(self):
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 typing import Any, List, Dict
from typing import Any
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db
@ -8,7 +8,6 @@ from ..database.types import Serial
class PluginSetting(db.Model):
__allow_unmapped__ = True
__tablename__ = "plugin_setting"
id = db.Column("id", Serial, primary_key=True)
plugin_id: int = db.Column("plugin", Serial, db.ForeignKey("plugin.id"))
@ -17,7 +16,6 @@ class PluginSetting(db.Model):
class BasePlugin(db.Model):
__allow_unmapped__ = True
__tablename__ = "plugin"
id: int = db.Column("id", Serial, primary_key=True)
name: str = db.Column(db.String(127), nullable=False)
@ -26,7 +24,7 @@ class BasePlugin(db.Model):
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
"""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"
)
"""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*.
"""
__settings: Dict[str, "PluginSetting"] = db.relationship(
__settings: dict[str, "PluginSetting"] = db.relationship(
"PluginSetting",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete, delete-orphan",
lazy="subquery",
lazy="select",
)
def get_setting(self, name: str, **kwargs):

View File

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

View File

@ -1,8 +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 Optional, Union, List
from typing import Optional
from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection
@ -23,21 +21,19 @@ role_permission_association_table = db.Table(
class Permission(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True)
id_ = db.Column("id", Serial, primary_key=True)
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):
__allow_unmapped__ = True
__tablename__ = "role"
id: int = db.Column(Serial, primary_key=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):
@ -47,7 +43,7 @@ class User(db.Model, ModelSerializeMixin):
Attributes:
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
firstname: Firstname of the User
lastname: Lastname of the User
@ -55,7 +51,6 @@ class User(db.Model, ModelSerializeMixin):
birthday: Birthday of the user
"""
__allow_unmapped__ = True
__tablename__ = "user"
userid: str = db.Column(db.String(30), unique=True, nullable=False)
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)
birthday: Optional[date] = db.Column(db.Date)
mail: str = db.Column(db.String(60))
roles: List[str] = []
permissions: Optional[list[str]] = []
roles: list[str] = []
permissions: Optional[list[str]] = None
# Protected stuff for backend use only
id_ = db.Column("id", Serial, primary_key=True)
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")
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")
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
_avatar_id = db.Column("avatar", Serial, db.ForeignKey("image.id"))
@ -83,7 +78,7 @@ class User(db.Model, ModelSerializeMixin):
)
@property
def roles(self) -> List[str]:
def roles(self):
return [role.name for role in self.roles_]
def set_attribute(self, name, value):
@ -112,7 +107,6 @@ class User(db.Model, ModelSerializeMixin):
class _UserAttribute(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "user_attribute"
id = db.Column("id", Serial, primary_key=True)
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):
"""Table containing password reset requests"""
__allow_unmapped__ = True
__tablename__ = "password_reset"
_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])

View File

@ -4,7 +4,7 @@
"""
from typing import Union, List
from typing import Union
from importlib.metadata import entry_points
from werkzeug.exceptions import NotFound
from werkzeug.datastructures import FileStorage
@ -100,7 +100,7 @@ class Plugin(BasePlugin):
@property
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]
def load(self):
@ -142,18 +142,7 @@ class Plugin(BasePlugin):
"""
from ..controller import pluginController
return pluginController.notify(self.id, 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)
return pluginController.notify(self.name, user, text, data)
def serialize(self):
"""Serialize a plugin into a dict
@ -169,13 +158,13 @@ class Plugin(BasePlugin):
Args:
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)
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 and isinstance(self.permissions, list) or []))
self.permissions.extend(_perms)
self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [
Permission(name=x, plugin_=self) for x in new_perms
]
class AuthPlugin(Plugin):
@ -248,16 +237,6 @@ class AuthPlugin(Plugin):
"""
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:
"""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():
data = request.get_json()
if "userid" in data:
user = userController.get_user(data["userid"])
user = userController.find_user(data["userid"])
if user:
userController.request_reset(user)
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.datastructures import FileStorage
from datetime import datetime
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.controller import userController
@ -128,12 +126,9 @@ class AuthLDAP(AuthPlugin):
def modify_user(self, user: User, password=None, new_password=None):
try:
dn = user.get_attribute("DN")
logger.debug(f"LDAP: modify_user for user {user.userid} with dn {dn}")
if password:
logger.debug(f"LDAP: modify_user for user {user.userid} with password")
ldap_conn = self.ldap.connect(dn, password)
else:
logger.debug(f"LDAP: modify_user for user {user.userid} with root_dn")
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
raise InternalServerError
@ -146,31 +141,14 @@ class AuthLDAP(AuthPlugin):
("display_name", "displayName"),
]:
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:
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)
self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError):
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):
self.ldap.connection.search(
self.search_dn,

View File

@ -6,15 +6,13 @@ from werkzeug.exceptions import NotFound
@click.command(no_args_is_help=True)
@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
@click.pass_context
def ldap(ctx, sync, sync_ldap):
def ldap(ctx, sync):
"""Tools for the LDAP authentification"""
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
if sync:
click.echo("Synchronizing users from LDAP -> database")
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
from ldap3 import SUBTREE
from flaschengeist.models import User
from flaschengeist.database import db
@ -35,13 +33,3 @@ def ldap(ctx, sync, sync_ldap):
user = User(userid=uid)
db.session.add(user)
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 werkzeug.exceptions import NotFound
from werkzeug.local import LocalProxy
from flaschengeist import logger
from flaschengeist.config import config
@ -57,7 +56,6 @@ def service_debit():
class BalancePlugin(Plugin):
# id = "dev.flaschengeist.balance"
models = models
def install(self):
@ -65,7 +63,6 @@ class BalancePlugin(Plugin):
def load(self):
from .routes import blueprint
self.blueprint = blueprint
@plugins_loaded
@ -74,7 +71,7 @@ class BalancePlugin(Plugin):
add_scheduled(f"{id}.service_debit", service_debit, minutes=1)
@before_update_user
def set_default_limit(user, *args):
def set_default_limit(user):
from . import balance_controller
try:
@ -83,7 +80,3 @@ class BalancePlugin(Plugin):
balance_controller.set_limit(user, limit, override=False)
except KeyError:
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
# Credit -> to account
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 datetime import datetime
from werkzeug.exceptions import BadRequest, NotFound, Conflict
from flaschengeist.database import db
from flaschengeist.models.user import User, _UserAttribute
from flaschengeist.app import logger
from .models import Transaction
from . import permissions, BalancePlugin
@ -21,8 +20,6 @@ __attribute_limit = "balance_limit"
class NotifyType(IntEnum):
SEND_TO = 0x01
SEND_FROM = 0x02
ADD_FROM = 0x03
SUB_FROM = 0x04
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):
query = db.session.query(func.sum(Transaction._amount))
query = db.session.query(func.sum(Transaction.amount))
if start:
query = query.filter(start <= Transaction.time)
if end:
@ -47,26 +44,10 @@ def get_balance(user, start: datetime = None, end: datetime = None):
return credit, debit, credit - debit
def get_balances(
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})"
)
def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None):
class _User(User):
_debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id])
_credit = db.relationship(
Transaction,
back_populates="receiver_",
foreign_keys=[Transaction._receiver_id],
)
_credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id])
@hybrid_property
def debit(self):
@ -75,8 +56,8 @@ def get_balances(
@debit.expression
def debit(cls):
a = (
db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._sender_id, Transaction._amount)
db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._sender_id, Transaction.amount)
.scalar_subquery()
)
return case([(a, a)], else_=0)
@ -88,8 +69,8 @@ def get_balances(
@credit.expression
def credit(cls):
b = (
db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._receiver_id, Transaction._amount)
db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._receiver_id, Transaction.amount)
.scalar_subquery()
)
return case([(b, b)], else_=0)
@ -102,12 +83,7 @@ def get_balances(
def limit(cls):
return (
db.select(_UserAttribute.value)
.where(
and_(
cls.id_ == _UserAttribute.user,
_UserAttribute.name == "balance_limit",
)
)
.where(and_(cls.id_ == _UserAttribute.user, _UserAttribute.name == "balance_limit"))
.scalar_subquery()
)
@ -140,27 +116,11 @@ def get_balances(
q2 = query.join(_User._debit).filter(Transaction.time <= end)
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 descending:
query = query.order_by(
(_User.credit - _User.debit).desc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc())
else:
query = query.order_by(
(_User.credit - _User.debit).asc(),
_User.lastname.asc(),
_User.firstname.asc(),
)
query = query.order_by((_User.credit - _User.debit).asc(), _User.lastname.asc(), _User.firstname.asc())
elif sortBy == "limit":
if descending:
query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc())
@ -187,6 +147,7 @@ def get_balances(
all = {}
for user in users:
all[user.userid] = [user.get_credit(start, end), 0]
all[user.userid][1] = user.get_debit(start, end)
@ -206,7 +167,6 @@ def send(sender: User, receiver, amount: float, author: User):
Raises:
BadRequest if amount <= 0
"""
logger.debug(f"send(sender={sender}, receiver={receiver}, amount={amount}, author={author})")
if amount <= 0:
raise BadRequest
@ -220,48 +180,20 @@ def send(sender: User, receiver, amount: float, author: User):
db.session.add(transaction)
db.session.commit()
if sender is not None and sender.id_ != author.id_:
if receiver is not None:
BalancePlugin.getPlugin().notify(
sender,
"Neue Transaktion",
{
"type": NotifyType.SEND_FROM,
"receiver_id": receiver.userid,
"author_id": author.userid,
"amount": amount,
},
)
else:
BalancePlugin.getPlugin().notify(
sender,
"Neue Transaktion",
{
"type": NotifyType.SUB_FROM,
"author_id": author.userid,
"amount": amount,
},
)
BalancePlugin.plugin.notify(
sender,
"Neue Transaktion",
{
"type": NotifyType.SEND_FROM,
"receiver_id": receiver.userid,
"author_id": author.userid,
"amount": amount,
},
)
if receiver is not None and receiver.id_ != author.id_:
if sender is not None:
BalancePlugin.getPlugin().notify(
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,
},
)
BalancePlugin.plugin.notify(
receiver, "Neue Transaktion", {"type": NotifyType.SEND_TO, "sender_id": sender.userid, "amount": amount}
)
return transaction

View File

@ -1,16 +1,13 @@
from datetime import datetime
from typing import Optional
from sqlalchemy.ext.hybrid import hybrid_property
from math import floor
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.models.user import User
from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
class Transaction(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = "balance_transaction"
# Protected foreign key properties
_receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id"))
@ -20,9 +17,8 @@ class Transaction(db.Model, ModelSerializeMixin):
# Public and exported member
id: int = db.Column("id", Serial, primary_key=True)
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"))
amount: float
# Dummy properties used for JSON serialization (userid instead of full user)
author_id: Optional[str] = None
@ -59,14 +55,3 @@ class Transaction(db.Model, ModelSerializeMixin):
@property
def original_id(self):
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 logging import log
from werkzeug.exceptions import Forbidden, BadRequest
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.decorators import login_required
from flaschengeist.controller import userController
from flaschengeist.app import logger
from . import BalancePlugin, balance_controller, permissions
@ -164,7 +162,6 @@ def get_balance(userid, current_session: Session):
end = datetime.now(tz=timezone.utc)
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]}
@ -226,7 +223,6 @@ def get_transactions(userid, current_session: Session):
show_cancelled=show_cancelled,
descending=descending,
)
logger.debug(f"transactions: {transactions}")
return {"transactions": transactions, "count": count}
@ -321,15 +317,7 @@ def get_balances(current_session: Session):
offset = request.args.get("offset", type=int)
descending = request.args.get("descending", False, type=bool)
sortBy = request.args.get("sortBy", type=str)
_filter = request.args.get("filter", None, type=str)
logger.debug(f"request.args: {request.args}")
balances, count = balance_controller.get_balances(
limit=limit,
offset=offset,
descending=descending,
sortBy=sortBy,
_filter=_filter,
)
balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy)
return jsonify(
{
"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()],

View File

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

View File

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

View File

@ -2,23 +2,19 @@
Provides routes used to manage users
"""
from datetime import datetime
from http.client import CREATED
from flask import Blueprint, Response, after_this_request, jsonify, make_response, request
from flask import Blueprint, request, jsonify, make_response
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.controller import userController
from flaschengeist.models import User
from flaschengeist.plugins import Plugin
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import extract_session, headers, login_required
from flaschengeist.models import User
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created, no_content
from . import permissions
from flaschengeist.utils.datetime import from_iso_format
class UsersPlugin(Plugin):
@ -61,7 +57,7 @@ def register():
@UsersPlugin.blueprint.route("/users", methods=["GET"])
@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):
"""List all existing users
@ -110,7 +106,7 @@ def frontend(userid, current_session):
raise Forbidden
if request.method == "POST":
if request.content_length > 1024**2:
if request.content_length > 1024 ** 2:
raise BadRequest
current_session.user_.set_attribute("frontend", request.get_json())
return no_content()
@ -122,13 +118,10 @@ def frontend(userid, current_session):
@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):
etag = None
if "If-None-Match" in request.headers:
etag = request.headers["If-None-Match"]
user = userController.get_user(userid)
return userController.load_avatar(user, etag)
return userController.load_avatar(user)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@ -225,9 +218,7 @@ def edit_user(userid, current_session):
userController.set_roles(user, roles)
userController.modify_user(user, password, new_password)
userController.update_user(
user,
)
userController.update_user(user)
return no_content()
@ -263,21 +254,3 @@ def shortcuts(userid, current_session):
user.set_attribute("users_link_shortcuts", data)
userController.persist()
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 werkzeug.exceptions import NotFound
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.controller import roleController, userController
@ -71,19 +70,12 @@ def user(add_role, delete, user):
if USER_KEY in ctx.meta:
userController.register(ctx.meta[USER_KEY], ctx.meta[USER_KEY]["password"])
else:
if not isinstance(user, list) or not isinstance(user, tuple):
user = [user]
for uid in user:
logger.debug(f"Userid: {uid}")
user = userController.get_user(uid)
logger.debug(f"User: {user}")
if delete:
logger.debug(f"Deleting user {user}")
userController.delete_user(user)
elif add_role:
logger.debug(f"Adding role {add_role} to user {user}")
role = roleController.get(add_role)
logger.debug(f"Role: {role}")
user.roles_.append(role)
userController.modify_user(user, None)
db.session.commit()

View File

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

View File

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