Compare commits

..

28 Commits

Author SHA1 Message Date
Tim Gröger 957796e90e Merge branch 'develop' 2024-01-18 16:00:11 +01:00
Tim Gröger 4f20a94f60 fix some func to get balance 2024-01-17 13:04:29 +01:00
Tim Gröger 001ef13014 remove links 2024-01-17 00:20:40 +01:00
Tim Gröger 0ae334620b update dependencies 2024-01-16 22:43:49 +01:00
Tim Gröger 645e2865a6 update dependencies 2024-01-16 22:34:35 +01:00
Tim Gröger bddb11d1b4 update version to 2.0.0 2024-01-16 19:49:56 +01:00
Tim Gröger cab172dc65 fix floor transaction with value which has more ziffers than scale #33 2023-05-17 14:47:40 +02:00
Tim Gröger b40d40644d if birthday is date then take it otherwise parse from string; prettier 2023-05-15 23:52:49 +02:00
Tim Gröger 319889ee43 (user) better avatar cache-control
etag is added to header,
If etag is the same a not modified will be respond
2023-05-12 17:12:36 +02:00
Tim Gröger 4be7cccadb (auth_ldap) add get_last_modified from provider 2023-05-12 17:11:18 +02:00
Tim Gröger 9077c9fd11 (balance) fix notifications
if only author and sender oder receiver exists, create special notifications
2023-05-10 01:12:41 +02:00
Tim Gröger d7428b2ed1 fix add role to user 2023-05-09 21:59:15 +02:00
Tim Gröger 5bab4a7cde fix update ldap, no none types pushed, add more debugging 2023-05-09 21:59:00 +02:00
Tim Gröger d8028c4681 fixed timeout in mailing #30 2023-05-09 21:25:19 +02:00
Tim Gröger 8b15a45902 add docker cmd, more debug, add migrations to package 2023-05-09 21:23:47 +02:00
Tim Gröger ae583a6d18 add black to pyproject.toml 2023-05-09 21:16:17 +02:00
Tim Gröger 193ffeff9d fix reset password
wrong method in userController was executed to get user
2023-05-05 10:00:49 +02:00
Tim Gröger 7eb30b662f fix mail-plugin
this fix load config the right way.
now you can install mail-plugin with
```flaschengeist plugin install mail && flaschengeist plugin enable mail```
2023-05-05 10:00:08 +02:00
Tim Gröger 11204662be (balance) add filter to search user 2023-05-03 14:03:59 +02:00
Tim Gröger cb0795a6ac add ua-parser to pares user-agent 2023-05-03 07:46:50 +02:00
Tim Gröger f7c8ae1037 blacked and add some typings 2023-05-03 06:30:42 +02:00
Tim Gröger e6c143ad92 fix json_encoder for flask 2.x 2023-05-03 06:29:55 +02:00
Tim Gröger 59f5d4529d add ide to gitignore 2023-05-03 06:29:28 +02:00
Tim Gröger f38fb334f1 add get notifications of plugin 2023-05-01 21:53:48 +02:00
Tim Gröger 47442fe211 fix Flask dependencie for #28 2023-04-09 21:15:07 +02:00
Tim Gröger af2c674ce4 fixed most deprecations from flask and sqlalchemy 2023-04-09 20:57:15 +02:00
Tim Gröger cfbb557539 Merge pull request 'feature/migrations, closes #19' (#20) from feature/migrations into develop
Reviewed-on: #20
2023-03-02 05:37:09 +00:00
Tim Gröger 29630387a5 Merge branch 'release/1.0' 2020-08-06 22:31:33 +02:00
33 changed files with 446 additions and 150 deletions

1
.gitignore vendored
View File

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

View File

@ -1,9 +1,12 @@
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 JSONEncoder, jsonify from flask.json import 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
@ -12,6 +15,8 @@ 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):
@ -34,6 +39,19 @@ 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"] = {}
@ -43,7 +61,9 @@ 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
@ -58,9 +78,11 @@ 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_encoder = CustomJSONEncoder app.json_provider_class = CustomJSONProvider
app.json = CustomJSONProvider(app)
CORS(app) CORS(app)
with app.app_context(): with app.app_context():

View File

@ -37,7 +37,6 @@ 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:
@ -81,7 +80,6 @@ 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:
@ -115,7 +113,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,6 +89,7 @@ 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))
@ -98,4 +99,5 @@ 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

@ -0,0 +1,54 @@
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 .InterfaceGenerator import InterfaceGenerator from flaschengeist.cli.InterfaceGenerator import InterfaceGenerator
gen = InterfaceGenerator(namespace, output, logger) gen = InterfaceGenerator(namespace, output, logger)
if not no_core: if not no_core:

View File

@ -53,7 +53,6 @@ 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,7 +10,6 @@ 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(os.environ.get("FLASCHENGEIST_CONF"))) paths.append(Path(str(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 from typing import Union, List
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,7 +23,11 @@ __required_plugins = ["users", "roles", "scheduler", "auth"]
def get_authentication_provider(): 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): def get_loaded_plugins(plugin_name: str = None):
@ -56,7 +60,7 @@ def get_enabled_plugins() -> list[Plugin]:
return enabled_plugins return enabled_plugins
def notify(plugin_id: str, user, text: str, data=None): def notify(plugin_id: int, user, text: str, data=None):
"""Create a new notification for an user """Create a new notification for an user
Args: Args:
@ -70,12 +74,23 @@ def notify(plugin_id: str, 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=plugin_id, user_=user) n = Notification(text=text, data=data, plugin_id_=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}")
@ -93,11 +108,14 @@ 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,6 +2,7 @@ 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
@ -11,33 +12,8 @@ from ..database import db
lifetime = 1800 lifetime = 1800
def __get_user_agent_platform(ua: str): def get_user_agent(request_headers):
if "Win" in ua: return user_agent_parser.Parse(request_headers.get("User-Agent", "") if request_headers else "")
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):
@ -60,13 +36,9 @@ 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 = request_headers.get("Sec-CH-UA-Platform", None) or __get_user_agent_platform( platform = user_agent["os"]["family"]
request_headers.get("User-Agent", "") browser = user_agent["user_agent"]["family"]
)
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
@ -96,14 +68,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=request_headers.get("Sec-CH-UA-Platform", None) platform=user_agent["os"]["family"],
or __get_user_agent_platform(request_headers.get("User-Agent", "")), browser=user_agent["user_agent"]["family"],
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,10 +1,14 @@
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 from datetime import datetime, timedelta, timezone, date
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
@ -15,7 +19,12 @@ 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 imageController, messageController, pluginController, sessionController from ..controller import (
imageController,
messageController,
pluginController,
sessionController,
)
from ..plugins import AuthPlugin from ..plugins import AuthPlugin
@ -41,15 +50,17 @@ def _generate_password_reset(user):
return reset return reset
def get_provider(userid: str): def get_provider(userid: str) -> AuthPlugin:
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: AuthPlugin): def update_user(user: User, backend: Optional[AuthPlugin] = None):
"""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])
@ -192,7 +203,11 @@ 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__", 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.add(user)
db.session.flush() db.session.flush()
@ -203,7 +218,10 @@ 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("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 # 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"
@ -225,6 +243,9 @@ 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")
@ -259,14 +280,22 @@ def register(data, passwd=None):
return user return user
def load_avatar(user: 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):
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) return send_file(BytesIO(avatar.binary), avatar.mimetype, etag=new_etag)
raise NotFound raise NotFound

View File

@ -6,7 +6,8 @@ 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(
@ -20,7 +21,7 @@ metadata = MetaData(
) )
db = SQLAlchemy(metadata=metadata) db = SQLAlchemy(metadata=metadata, session_options={"expire_on_commit": False})
migrate = Migrate() migrate = Migrate()

View File

@ -16,13 +16,16 @@ class ModelSerializeMixin:
module = import_module("flaschengeist.models").__dict__ module = import_module("flaschengeist.models").__dict__
hint = typing.get_type_hints(self.__class__, globalns=module)[param] try:
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
@ -35,7 +38,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:
key, value = d.popitem() _, value = d.popitem()
return value return value
return d return d

View File

@ -8,6 +8,7 @@ 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,6 +8,7 @@ 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)
@ -20,7 +21,8 @@ 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): def plugin(self) -> str:
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 from typing import Any, List, Dict
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,6 +8,7 @@ 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"))
@ -16,6 +17,7 @@ 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)
@ -24,7 +26,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 = db.relationship( permissions: List["Permission"] = 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
@ -33,11 +35,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="select", lazy="subquery",
) )
def get_setting(self, name: str, **kwargs): def get_setting(self, name: str, **kwargs):

View File

@ -17,6 +17,7 @@ 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,6 +1,8 @@
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 from typing import Optional, Union, List
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
@ -21,19 +23,21 @@ 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="select", back_populates="permissions", enable_typechecks=False) plugin_ = db.relationship("Plugin", lazy="subquery", 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):
@ -43,7 +47,7 @@ class User(db.Model, ModelSerializeMixin):
Attributes: Attributes:
id: Id in Database as Primary Key. id: Id in Database as Primary Key.
uid: User ID used by authentication provider userid: 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
@ -51,6 +55,7 @@ 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))
@ -59,15 +64,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]] = None permissions: Optional[list[str]] = []
# 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"))
@ -78,7 +83,7 @@ class User(db.Model, ModelSerializeMixin):
) )
@property @property
def roles(self): def roles(self) -> List[str]:
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):
@ -107,6 +112,7 @@ 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)
@ -117,6 +123,7 @@ 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 from typing import Union, List
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 = entry_points(group="flaschengeist.plugins", name=self.name) ep = tuple(entry_points(group="flaschengeist.plugins", name=self.name))
return ep[0] return ep[0]
def load(self): def load(self):
@ -142,7 +142,18 @@ class Plugin(BasePlugin):
""" """
from ..controller import pluginController from ..controller import pluginController
return pluginController.notify(self.name, user, text, data) 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)
def serialize(self): def serialize(self):
"""Serialize a plugin into a dict """Serialize a plugin into a dict
@ -158,13 +169,13 @@ class Plugin(BasePlugin):
Args: Args:
permissions: List of permissions to install permissions: List of permissions to install
""" """
cur_perm = set(x.name for x in self.permissions) cur_perm = set(x for x in self.permissions or [])
all_perm = set(permissions) all_perm = set(permissions)
new_perms = all_perm - cur_perm new_perms = all_perm - cur_perm
self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [ _perms = [Permission(name=x, plugin_=self) for x in new_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)
class AuthPlugin(Plugin): class AuthPlugin(Plugin):
@ -237,6 +248,16 @@ 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.find_user(data["userid"]) user = userController.get_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,6 +10,8 @@ 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
@ -126,9 +128,12 @@ 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
@ -141,14 +146,31 @@ 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,13 +6,15 @@ 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): def ldap(ctx, sync, sync_ldap):
"""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
@ -33,3 +35,13 @@ def ldap(ctx, sync):
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,6 +5,7 @@ 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
@ -56,6 +57,7 @@ def service_debit():
class BalancePlugin(Plugin): class BalancePlugin(Plugin):
# id = "dev.flaschengeist.balance"
models = models models = models
def install(self): def install(self):
@ -63,6 +65,7 @@ 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
@ -71,7 +74,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): def set_default_limit(user, *args):
from . import balance_controller from . import balance_controller
try: try:
@ -80,3 +83,7 @@ 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,13 +3,14 @@
# 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_ from sqlalchemy import func, case, and_, or_
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
@ -20,6 +21,8 @@ __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):
@ -33,7 +36,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:
@ -44,10 +47,26 @@ def get_balance(user, start: datetime = None, end: datetime = None):
return credit, debit, credit - debit return credit, debit, credit - debit
def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None): 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})"
)
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(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id]) _credit = db.relationship(
Transaction,
back_populates="receiver_",
foreign_keys=[Transaction._receiver_id],
)
@hybrid_property @hybrid_property
def debit(self): def debit(self):
@ -56,8 +75,8 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
@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)
@ -69,8 +88,8 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
@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)
@ -83,7 +102,12 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
def limit(cls): def limit(cls):
return ( return (
db.select(_UserAttribute.value) 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() .scalar_subquery()
) )
@ -116,11 +140,27 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
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((_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: 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": 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())
@ -147,7 +187,6 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
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)
@ -167,6 +206,7 @@ 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
@ -180,7 +220,8 @@ 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_:
BalancePlugin.plugin.notify( if receiver is not None:
BalancePlugin.getPlugin().notify(
sender, sender,
"Neue Transaktion", "Neue Transaktion",
{ {
@ -190,9 +231,36 @@ 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_:
BalancePlugin.plugin.notify( if sender is not None:
receiver, "Neue Transaktion", {"type": NotifyType.SEND_TO, "sender_id": sender.userid, "amount": amount} 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,
},
) )
return transaction return transaction

View File

@ -1,13 +1,16 @@
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"))
@ -17,8 +20,9 @@ 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(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) _amount: float = db.Column("amount", 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
@ -55,3 +59,14 @@ 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,4 +1,5 @@
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
@ -7,6 +8,7 @@ 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
@ -162,6 +164,7 @@ 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]}
@ -223,6 +226,7 @@ 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}
@ -317,7 +321,15 @@ 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)
balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy) _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,
)
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,6 +1,7 @@
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
@ -8,23 +9,29 @@ 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 __init__(self, entry_point, config): def load(self):
super().__init__(entry_point, config) self.config = config.get("mail", None)
self.server = config["SERVER"] if self.config is None:
self.port = config["PORT"] logger.error("mail was not configured in flaschengeist.toml")
self.user = config["USER"] raise InternalServerError
self.password = config["PASSWORD"] self.server = self.config["SERVER"]
self.crypt = config["CRYPT"] self.port = self.config["PORT"]
self.mail = config["MAIL"] self.user = self.config["USER"]
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): def dummy_send(msg, *args, **kwargs):
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))
@ -38,9 +45,8 @@ 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))
if not hasattr(self, "smtp"): with self.__connect() as smtp:
self.__connect() smtp.sendmail(self.mail, recipients, mail.as_string())
self.smtp.sendmail(self.mail, recipients, mail.as_string())
def __connect(self): def __connect(self):
if self.crypt == "SSL": if self.crypt == "SSL":
@ -51,3 +57,4 @@ 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,6 +61,7 @@ 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

@ -3,8 +3,9 @@
Provides routes used to manage users Provides routes used to manage users
""" """
from http.client import CREATED from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response from flask import Blueprint, request, jsonify, make_response, after_this_request, Response
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from datetime import datetime
from . import permissions from . import permissions
from flaschengeist import logger from flaschengeist import logger
@ -118,10 +119,13 @@ 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, max-age=604800"}) @headers({"Cache-Control": "public, must-revalidate, max-age=10"})
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) return userController.load_avatar(user, etag)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"]) @UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@ -218,7 +222,9 @@ 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(user) userController.update_user(
user,
)
return no_content() return no_content()

View File

@ -3,6 +3,7 @@ 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
@ -70,12 +71,19 @@ 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,3 +1,6 @@
[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.0.0.dev0 version = 2.0.0
name = flaschengeist name = flaschengeist
author = Tim Gröger author = Tim Gröger
author_email = flaschengeist@wu5.de author_email = flaschengeist@wu5.de
@ -22,7 +22,8 @@ include_package_data = True
python_requires = >=3.10 python_requires = >=3.10
packages = find: packages = find:
install_requires = install_requires =
Flask>=2.2.2 #Flask>=2.2.2, <2.3
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
@ -30,9 +31,11 @@ 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
@ -43,7 +46,7 @@ mysql =
mysqlclient;platform_system!='Windows' mysqlclient;platform_system!='Windows'
[options.package_data] [options.package_data]
* = *.toml, script.py.mako * = *.toml, script.py.mako, *.ini, */migrations/*, migrations/versions/*
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =