Compare commits

...

30 Commits

Author SHA1 Message Date
Tim Gröger 83921f3f54 update to version 2.1.0 2024-10-08 13:29:37 +00:00
Tim Gröger bc21aefe93 [feat] add user settings 2024-10-08 13:29:37 +00:00
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 474 additions and 157 deletions

1
.gitignore vendored
View File

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

View File

@ -1,9 +1,12 @@
import enum
import json
from flask import Flask
from flask_cors import CORS
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 werkzeug.exceptions import HTTPException
@ -12,6 +15,8 @@ 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):
@ -34,6 +39,19 @@ 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"] = {}
@ -43,7 +61,9 @@ 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 = 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
plugin.load()
# Register blueprint
@ -58,9 +78,11 @@ 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_encoder = CustomJSONEncoder
app.json_provider_class = CustomJSONProvider
app.json = CustomJSONProvider(app)
CORS(app)
with app.app_context():

View File

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

@ -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)
def export(namespace, output, no_core, plugin):
from flaschengeist import logger, models
from .InterfaceGenerator import InterfaceGenerator
from flaschengeist.cli.InterfaceGenerator import InterfaceGenerator
gen = InterfaceGenerator(namespace, output, logger)
if not no_core:

View File

@ -53,7 +53,6 @@ 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,7 +10,6 @@ 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(os.environ.get("FLASCHENGEIST_CONF")))
paths.append(Path(str(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
from typing import Union, List
from flask import current_app
from werkzeug.exceptions import NotFound, BadRequest
from sqlalchemy.exc import OperationalError, ProgrammingError
@ -23,7 +23,11 @@ __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):
@ -56,7 +60,7 @@ def get_enabled_plugins() -> list[Plugin]:
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
Args:
@ -70,12 +74,23 @@ def notify(plugin_id: str, user, text: str, data=None):
Hint: use the data for frontend actions.
"""
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.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}")
@ -93,11 +108,14 @@ 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,6 +2,7 @@ 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
@ -11,33 +12,8 @@ from ..database import db
lifetime = 1800
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 get_user_agent(request_headers):
return user_agent_parser.Parse(request_headers.get("User-Agent", "") if request_headers else "")
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()
if session:
logger.debug("token found, check if expired or invalid user agent differs")
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", "")
)
user_agent = get_user_agent(request_headers)
platform = user_agent["os"]["family"]
browser = user_agent["user_agent"]["family"]
if session.expires >= datetime.now(timezone.utc) and (
session.browser == browser and session.platform == platform
@ -96,14 +68,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=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", "")),
platform=user_agent["os"]["family"],
browser=user_agent["user_agent"]["family"],
)
session.refresh()
db.session.add(session)

View File

@ -1,10 +1,14 @@
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
from datetime import datetime, timedelta, timezone, date
from flask.helpers import send_file
from werkzeug.exceptions import NotFound, BadRequest, Forbidden
@ -15,7 +19,12 @@ 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
@ -41,15 +50,17 @@ def _generate_password_reset(user):
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]
@Hook
def update_user(user: User, backend: AuthPlugin):
def update_user(user: User, backend: Optional[AuthPlugin] = None):
"""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])
@ -192,7 +203,11 @@ 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()
@ -203,7 +218,10 @@ 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"
@ -225,7 +243,10 @@ 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:
values["birthday"] = from_iso_format(data["birthday"]).date()
if isinstance(data["birthday"], date):
values["birthday"] = data["birthday"]
else:
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)
@ -259,14 +280,22 @@ def register(data, passwd=None):
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:
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)
return send_file(BytesIO(avatar.binary), avatar.mimetype, etag=new_etag)
raise NotFound

View File

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

View File

@ -16,13 +16,16 @@ class ModelSerializeMixin:
module = import_module("flaschengeist.models").__dict__
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
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
def serialize(self):
"""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 len(d) == 1:
key, value = d.popitem()
_, value = d.popitem()
return value
return d

View File

@ -8,6 +8,7 @@ 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,6 +8,7 @@ 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)
@ -20,7 +21,8 @@ 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):
def plugin(self) -> str:
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
from typing import Any, List, Dict
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db
@ -8,6 +8,7 @@ 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"))
@ -16,6 +17,7 @@ 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)
@ -24,7 +26,7 @@ class BasePlugin(db.Model):
"""The latest installed version"""
enabled: bool = db.Column(db.Boolean, default=False)
"""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"
)
"""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*.
"""
__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="select",
lazy="subquery",
)
def get_setting(self, name: str, **kwargs):

View File

@ -17,6 +17,7 @@ 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,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 sqlalchemy.orm.collections import attribute_mapped_collection
@ -21,19 +23,21 @@ 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="select", back_populates="permissions", enable_typechecks=False)
plugin_ = db.relationship("Plugin", lazy="subquery", 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):
@ -43,7 +47,7 @@ class User(db.Model, ModelSerializeMixin):
Attributes:
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
firstname: Firstname of the User
lastname: Lastname of the User
@ -51,6 +55,7 @@ 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))
@ -59,15 +64,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]] = None
roles: List[str] = []
permissions: Optional[list[str]] = []
# 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"))
@ -78,7 +83,7 @@ class User(db.Model, ModelSerializeMixin):
)
@property
def roles(self):
def roles(self) -> List[str]:
return [role.name for role in self.roles_]
def set_attribute(self, name, value):
@ -107,6 +112,7 @@ 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)
@ -117,6 +123,7 @@ 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
from typing import Union, List
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 = entry_points(group="flaschengeist.plugins", name=self.name)
ep = tuple(entry_points(group="flaschengeist.plugins", name=self.name))
return ep[0]
def load(self):
@ -142,7 +142,18 @@ class Plugin(BasePlugin):
"""
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):
"""Serialize a plugin into a dict
@ -158,13 +169,13 @@ class Plugin(BasePlugin):
Args:
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)
new_perms = all_perm - cur_perm
self.permissions = list(filter(lambda x: x.name in permissions, self.permissions)) + [
Permission(name=x, plugin_=self) for x in new_perms
]
_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):
@ -237,6 +248,16 @@ 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.find_user(data["userid"])
user = userController.get_user(data["userid"])
if user:
userController.request_reset(user)
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.datastructures import FileStorage
from datetime import datetime
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.controller import userController
@ -126,9 +128,12 @@ 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
@ -141,14 +146,31 @@ class AuthLDAP(AuthPlugin):
("display_name", "displayName"),
]:
if hasattr(user, name):
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
attribute = getattr(user, name)
if attribute:
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,13 +6,15 @@ 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):
def ldap(ctx, sync, sync_ldap):
"""Tools for the LDAP authentification"""
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
if sync:
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
click.echo("Synchronizing users from LDAP -> database")
from ldap3 import SUBTREE
from flaschengeist.models import User
from flaschengeist.database import db
@ -33,3 +35,13 @@ def ldap(ctx, sync):
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,6 +5,7 @@ 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
@ -56,6 +57,7 @@ def service_debit():
class BalancePlugin(Plugin):
# id = "dev.flaschengeist.balance"
models = models
def install(self):
@ -63,6 +65,7 @@ class BalancePlugin(Plugin):
def load(self):
from .routes import blueprint
self.blueprint = blueprint
@plugins_loaded
@ -71,7 +74,7 @@ class BalancePlugin(Plugin):
add_scheduled(f"{id}.service_debit", service_debit, minutes=1)
@before_update_user
def set_default_limit(user):
def set_default_limit(user, *args):
from . import balance_controller
try:
@ -80,3 +83,7 @@ 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,13 +3,14 @@
# English: Debit -> from account
# Credit -> to account
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 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
@ -20,6 +21,8 @@ __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):
@ -33,7 +36,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:
@ -44,10 +47,26 @@ 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):
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):
_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):
@ -56,8 +75,8 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
@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)
@ -69,8 +88,8 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
@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)
@ -83,7 +102,12 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
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()
)
@ -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)
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())
@ -147,7 +187,6 @@ def get_balances(start: datetime = None, end: datetime = None, limit=None, offse
all = {}
for user in users:
all[user.userid] = [user.get_credit(start, end), 0]
all[user.userid][1] = user.get_debit(start, end)
@ -167,6 +206,7 @@ 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
@ -180,20 +220,48 @@ 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_:
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:
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,
},
)
if receiver is not None and receiver.id_ != author.id_:
BalancePlugin.plugin.notify(
receiver, "Neue Transaktion", {"type": NotifyType.SEND_TO, "sender_id": sender.userid, "amount": amount}
)
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,
},
)
return transaction

View File

@ -1,13 +1,16 @@
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"))
@ -17,8 +20,9 @@ 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(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"))
amount: float
# Dummy properties used for JSON serialization (userid instead of full user)
author_id: Optional[str] = None
@ -55,3 +59,14 @@ 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,4 +1,5 @@
from datetime import datetime, timezone
from logging import log
from werkzeug.exceptions import Forbidden, BadRequest
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.decorators import login_required
from flaschengeist.controller import userController
from flaschengeist.app import logger
from . import BalancePlugin, balance_controller, permissions
@ -162,6 +164,7 @@ 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]}
@ -223,6 +226,7 @@ def get_transactions(userid, current_session: Session):
show_cancelled=show_cancelled,
descending=descending,
)
logger.debug(f"transactions: {transactions}")
return {"transactions": transactions, "count": count}
@ -317,7 +321,15 @@ 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)
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(
{
"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()],

View File

@ -1,6 +1,7 @@
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
@ -8,23 +9,29 @@ 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 __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"]
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"]
@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)
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))
@ -38,9 +45,8 @@ class MailMessagePlugin(Plugin):
mail["To"] = ", ".join(recipients)
mail["Subject"] = msg.subject
mail.attach(MIMEText(msg.message))
if not hasattr(self, "smtp"):
self.__connect()
self.smtp.sendmail(self.mail, recipients, mail.as_string())
with self.__connect() as smtp:
smtp.sendmail(self.mail, recipients, mail.as_string())
def __connect(self):
if self.crypt == "SSL":
@ -51,3 +57,4 @@ class MailMessagePlugin(Plugin):
else:
raise ValueError("Invalid CRYPT given")
self.smtp.login(self.user, self.password)
return self.smtp

View File

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

View File

@ -2,19 +2,23 @@
Provides routes used to manage users
"""
from datetime import datetime
from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response
from flask import Blueprint, Response, after_this_request, jsonify, make_response, request
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.plugins import Plugin
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 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.utils.HTTP import created, no_content
from . import permissions
class UsersPlugin(Plugin):
@ -57,7 +61,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
@ -106,7 +110,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()
@ -118,10 +122,13 @@ def frontend(userid, current_session):
@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):
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)
return userController.load_avatar(user, etag)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@ -218,7 +225,9 @@ 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()
@ -254,3 +263,21 @@ 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,6 +3,7 @@ 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
@ -70,12 +71,19 @@ 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,3 +1,6 @@
[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.0.0.dev0
version = 2.1.0
name = flaschengeist
author = Tim Gröger
author_email = flaschengeist@wu5.de
@ -22,7 +22,8 @@ include_package_data = True
python_requires = >=3.10
packages = find:
install_requires =
Flask>=2.2.2
#Flask>=2.2.2, <2.3
Flask>=2.2.2, <2.9
Pillow>=9.2
flask_cors
flask_migrate>=3.1.0
@ -30,20 +31,22 @@ 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>=1.4.40, <2.0
sqlalchemy >= 2.0
toml
werkzeug>=2.2.2
ua-parser>=0.16.1
[options.extras_require]
argon = argon2-cffi
ldap = flask_ldapconn; ldap3
ldap = flask_ldapconn @ git+https://github.com/rroemhild/flask-ldapconn.git; ldap3
tests = pytest; pytest-depends; coverage
mysql =
PyMySQL;platform_system=='Windows'
mysqlclient;platform_system!='Windows'
[options.package_data]
* = *.toml, script.py.mako
* = *.toml, script.py.mako, *.ini, */migrations/*, migrations/versions/*
[options.entry_points]
console_scripts =
@ -73,4 +76,4 @@ testpaths = tests
[coverage:run]
branch = True
source = flaschengeist
source = flaschengeist