Compare commits

..

22 Commits

Author SHA1 Message Date
Tim Gröger 607b29027b update to version 2.2.0 2024-10-15 08:30:03 +02:00
Tim Gröger df02808fb7 [feat][apiKey] show token only on create, fix decorator 2024-10-15 08:26:16 +02:00
Tim Gröger 7dd3321246 [feat][apikey] update model, add logic, add routes 2024-10-15 07:27:29 +02:00
Tim Gröger 2f7fdec492 [feat] add api_key table
create model for api_key
create migration for alembic
2024-10-14 06:24:19 +02:00
Tim Gröger 81080404fb [fix] fix multiple sessions
Session is created with user.id now instead of full user. Old sessions
dont will destroy.
2024-10-10 13:29:05 +02:00
Tim Gröger 0570a9a32f update to version 2.1.0 2024-10-08 15:05:08 +02:00
Tim Gröger c06a12faaa [feat] add user settings 2024-04-12 10:11:45 +02: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
24 changed files with 610 additions and 76 deletions

View File

@ -0,0 +1,37 @@
"""add name and description to api_key
Revision ID: 49118ea16b56
Revises: f9aa4cafa982
Create Date: 2024-10-14 08:15:16.348090
"""
import sqlalchemy as sa
from alembic import op
import flaschengeist
# revision identifiers, used by Alembic.
revision = "49118ea16b56"
down_revision = "f9aa4cafa982"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("api_key", schema=None) as batch_op:
batch_op.add_column(sa.Column("name", sa.String(length=32), nullable=True))
batch_op.add_column(sa.Column("description", sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table("api_key", schema=None) as batch_op:
batch_op.drop_column("description")
batch_op.drop_column("name")
# ### end Alembic commands ###

View File

@ -0,0 +1,41 @@
"""Add APIKeys
Revision ID: f9aa4cafa982
Revises: 20482a003db8
Create Date: 2024-10-11 13:04:21.877288
"""
import sqlalchemy as sa
from alembic import op
import flaschengeist
# revision identifiers, used by Alembic.
revision = "f9aa4cafa982"
down_revision = "20482a003db8"
branch_labels = ()
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"api_key",
sa.Column("expires", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("token", sa.String(length=32), nullable=True),
sa.Column("lifetime", sa.Integer(), nullable=True),
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_api_key_user_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_api_key")),
sa.UniqueConstraint("token", name=op.f("uq_api_key_token")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("api_key")
# ### end Alembic commands ###

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

@ -0,0 +1,79 @@
import secrets
from werkzeug.exceptions import Unauthorized
from .. import logger
from ..database import db
from ..models import ApiKey
def validate_api_key(api_key, permission):
"""Verify api key
Verify a ApiKey so if the User has permission or not.
Retrieves the access token if valid else retrieves False
Args:
api_key: ApiKey to verify
permission: Permission needed to access restricted routes
Returns:
A ApiKey for this given Token
Raises:
Unauthorized: If api key is invalid
Forbidden: If permission is insufficient
"""
logger.debug("check api_key {{ {} }} is valid".format(api_key))
api_key = ApiKey.query.filter_by(_token=api_key).one_or_none()
if api_key:
logger.debug("api_key found")
if not permission or api_key.user_.has_permission(permission):
return api_key
else:
raise Forbidden
logger.debug("no valid api key with api_key: {{ {} }} and permission: {{ {} }}".format(api_key, permission))
raise Unauthorized
def create(user, name, description=None) -> ApiKey:
"""Create a ApiKey
Args:
user: For which User is to create a ApiKey
Returns:
A ApiKey for this given User
"""
logger.debug("create api key token")
token_str = secrets.token_hex(16)
logger.debug("create api_key for user {{ {} }}".format(user))
api_key = ApiKey(_user_id=user.id_, name=name, description=description, _token=token_str)
db.session.add(api_key)
db.session.commit()
api_key.token = api_key._token
return api_key
def get_users_api_keys(user) -> list[ApiKey]:
"""Get all ApiKeys for a User
Args:
user: For which User is to get all ApiKeys
Returns:
List of ApiKeys for this given User
"""
return ApiKey.query.filter(ApiKey._user_id == user.id_).all()
def delete_api_key(api_key):
"""Delete a ApiKey
Args:
api_key: ApiKey to delete
"""
logger.debug(f"delete api_key {{ {api_key} }} {{ {type(api_key)} }}")
if isinstance(api_key, int):
api_key = ApiKey.query.get(api_key)
logger.debug("delete api_key {{ {} }}".format(api_key.token))
db.session.delete(api_key)
db.session.commit()

View File

@ -108,9 +108,12 @@ 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() db.session.commit()
return plugin return plugin

View File

@ -1,13 +1,12 @@
import secrets import secrets
from datetime import datetime, timezone from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, Unauthorized
from ua_parser import user_agent_parser from ua_parser import user_agent_parser
from werkzeug.exceptions import Forbidden, Unauthorized
from .. import logger from .. import logger
from ..models import Session
from ..database import db from ..database import db
from ..models import Session
lifetime = 1800 lifetime = 1800
@ -72,7 +71,7 @@ def create(user, request_headers=None) -> Session:
logger.debug(f"platform: {user_agent['os']['family']}, browser: {user_agent['user_agent']['family']}") 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_id=user.id_,
lifetime=lifetime, lifetime=lifetime,
platform=user_agent["os"]["family"], platform=user_agent["os"]["family"],
browser=user_agent["user_agent"]["family"], browser=user_agent["user_agent"]["family"],

View File

@ -1,11 +1,14 @@
import re import re
import secrets import secrets
import hashlib
from io import BytesIO from io import BytesIO
from typing import Optional 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
@ -16,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
@ -195,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()
@ -206,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"
@ -228,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")
@ -262,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

@ -1,5 +1,6 @@
from .api_key import *
from .image import *
from .notification import *
from .plugin import *
from .session import * from .session import *
from .user import * from .user import *
from .plugin import *
from .notification import *
from .image import *

View File

@ -0,0 +1,52 @@
from __future__ import \
annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from datetime import datetime, timedelta, timezone
from secrets import compare_digest
from typing import Union
from .. import logger
from ..database import db
from ..database.types import ModelSerializeMixin, Serial, UtcDateTime
class ApiKey(db.Model, ModelSerializeMixin):
"""Model for a Session
Args:
expires: Is a Datetime from current Time.
user: Is an User.
token: String to verify access later.
"""
__allow_unmapped__ = True
__tablename__ = "api_key"
expires: datetime = db.Column(UtcDateTime, nullable=True)
_token: str = db.Column("token", db.String(32), unique=True)
name: str = db.Column(db.String(32))
description: str = db.Column(db.String(255), nullable=True)
lifetime: int = db.Column(db.Integer, nullable=True)
userid: str = ""
id: int = db.Column("id", Serial, primary_key=True)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"))
user_: User = db.relationship("User", back_populates="api_keys_")
token: Union[str, None] = None
@property
def userid(self):
return self.user_.userid
def refresh(self):
"""Update the Timestamp
Update the Timestamp to the current Time.
"""
logger.debug("update timestamp from session with token {{ {} }}".format(self._token))
self.expires = datetime.now(timezone.utc) + timedelta(seconds=self.lifetime)
def __eq__(self, token):
if isinstance(token, str):
return compare_digest(self._token, token)
else:
return super(Session, self).__eq__(token)

View File

@ -1,11 +1,13 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered) from __future__ import \
annotations # TODO: Remove if python requirement is >= 3.12 (? PEP 563 is defered)
from typing import Optional, Union, List
from datetime import date, datetime from datetime import date, datetime
from typing import List, Optional, Union
from sqlalchemy.orm.collections import attribute_mapped_collection from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db from ..database import db
from ..database.types import ModelSerializeMixin, UtcDateTime, Serial from ..database.types import ModelSerializeMixin, Serial, UtcDateTime
association_table = db.Table( association_table = db.Table(
"user_x_role", "user_x_role",
@ -45,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
@ -69,6 +71,7 @@ class User(db.Model, ModelSerializeMixin):
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")
api_keys_: List[ApiKey] = db.relationship("ApiKey", 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")

View File

@ -248,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

@ -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
@ -82,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

@ -21,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):
@ -34,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:
@ -46,7 +48,13 @@ def get_balance(user, start: datetime = None, end: datetime = None):
def get_balances( def get_balances(
start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None, _filter=None start: datetime = None,
end: datetime = None,
limit=None,
offset=None,
descending=None,
sortBy=None,
_filter=None,
): ):
logger.debug( logger.debug(
f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})" f"get_balances(start={start}, end={end}, limit={limit}, offset={offset}, descending={descending}, sortBy={sortBy}, _filter={_filter})"
@ -54,7 +62,11 @@ def get_balances(
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):
@ -63,8 +75,8 @@ def get_balances(
@debit.expression @debit.expression
def debit(cls): def debit(cls):
a = ( a = (
db.select(func.sum(Transaction.amount)) db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._sender_id, Transaction.amount) .where(cls.id_ == Transaction._sender_id, Transaction._amount)
.scalar_subquery() .scalar_subquery()
) )
return case([(a, a)], else_=0) return case([(a, a)], else_=0)
@ -76,8 +88,8 @@ def get_balances(
@credit.expression @credit.expression
def credit(cls): def credit(cls):
b = ( b = (
db.select(func.sum(Transaction.amount)) db.select(func.sum(Transaction._amount))
.where(cls.id_ == Transaction._receiver_id, Transaction.amount) .where(cls.id_ == Transaction._receiver_id, Transaction._amount)
.scalar_subquery() .scalar_subquery()
) )
return case([(b, b)], else_=0) return case([(b, b)], else_=0)
@ -90,7 +102,12 @@ def get_balances(
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()
) )
@ -125,14 +142,25 @@ def get_balances(
if _filter: if _filter:
query = query.filter( query = query.filter(
or_(_User.firstname.ilike(f"%{_filter.lower()}%"), _User.lastname.ilike(f"%{_filter.lower()}%")) 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())
@ -178,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
@ -191,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",
{ {
@ -201,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,7 +1,9 @@
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
@ -18,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
@ -56,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
@ -163,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]}
@ -224,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}
@ -321,7 +324,11 @@ def get_balances(current_session: Session):
_filter = request.args.get("filter", None, type=str) _filter = request.args.get("filter", None, type=str)
logger.debug(f"request.args: {request.args}") logger.debug(f"request.args: {request.args}")
balances, count = balance_controller.get_balances( balances, count = balance_controller.get_balances(
limit=limit, offset=offset, descending=descending, sortBy=sortBy, _filter=_filter limit=limit,
offset=offset,
descending=descending,
sortBy=sortBy,
_filter=_filter,
) )
return jsonify( return jsonify(
{ {

View File

@ -31,7 +31,7 @@ class MailMessagePlugin(Plugin):
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}") 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))
@ -41,18 +41,12 @@ class MailMessagePlugin(Plugin):
recipients = userController.get_user_by_role(msg.receiver) recipients = userController.get_user_by_role(msg.receiver)
mail = MIMEMultipart() mail = MIMEMultipart()
try:
mail["From"] = self.mail mail["From"] = self.mail
mail["To"] = ", ".join(recipients) mail["To"] = ", ".join(recipients)
except Exception as e:
import traceback
print(traceback.format_exc())
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":
@ -63,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

@ -2,19 +2,23 @@
Provides routes used to manage users Provides routes used to manage users
""" """
from datetime import datetime
from http.client import CREATED from http.client import CREATED
from flask import Blueprint, request, jsonify, make_response
from flask import Blueprint, Response, after_this_request, jsonify, make_response, request
from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed from werkzeug.exceptions import BadRequest, Forbidden, MethodNotAllowed
from . import permissions
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config from flaschengeist.config import config
from flaschengeist.plugins import Plugin from flaschengeist.controller import apiKeyController, userController
from flaschengeist.models import User from flaschengeist.models import User
from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.plugins import Plugin
from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format 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): class UsersPlugin(Plugin):
@ -57,7 +61,7 @@ def register():
@UsersPlugin.blueprint.route("/users", methods=["GET"]) @UsersPlugin.blueprint.route("/users", methods=["GET"])
@login_required() @login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"}) # @headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def list_users(current_session): def list_users(current_session):
"""List all existing users """List all existing users
@ -118,10 +122,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"])
@ -256,3 +263,82 @@ def shortcuts(userid, current_session):
user.set_attribute("users_link_shortcuts", data) user.set_attribute("users_link_shortcuts", data)
userController.persist() userController.persist()
return no_content() return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/setting/<setting>", methods=["GET", "PUT"])
@login_required()
def settings(userid, setting, current_session):
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
retVal = user.get_attribute(setting, None)
logger.debug(f"Get setting >>{setting}<< for user >>{user.userid}<< with >>{retVal}<<")
return jsonify(retVal)
else:
data = request.get_json()
logger.debug(f"Set setting >>{setting}<< for user >>{user.userid}<< to >>{data}<<")
user.set_attribute(setting, data)
userController.persist()
return no_content()
@UsersPlugin.blueprint.route("/users/<userid>/api_keys", methods=["GET"])
@login_required()
def get_users_api_keys(userid, current_session):
"""Get all API keys of a user
Route: ``/users/<userid>/api_keys`` | Method: ``GET``
Args:
userid: UserID of user to retrieve
current_session: Session sent with Authorization Header
Returns:
JSON encoded array of `flaschengeist.models.api_key.ApiKey` or HTTP error
"""
if userid != current_session.user_.userid:
raise Unauthorized
return jsonify(apiKeyController.get_users_api_keys(current_session.user_))
@UsersPlugin.blueprint.route("/users/<userid>/api_keys", methods=["POST"])
@login_required()
def create_api_key(userid, current_session):
"""Create a new API key for a user
Route: ``/users/<userid>/api_keys`` | Method: ``POST``
Args:
userid: UserID of user to retrieve
current_session: Session sent with Authorization Header
Returns:
JSON encoded `flaschengeist.models.api_key.ApiKey` or HTTP error
"""
data = request.get_json()
if not data or "name" not in data:
raise BadRequest
if userid != current_session.user_.userid:
raise Unauthorized
return jsonify(apiKeyController.create(current_session.user_, data["name"], data.get("description", None)))
@UsersPlugin.blueprint.route("/users/<userid>/api_keys/<int:keyid>", methods=["DELETE"])
@login_required()
def delete_api_key(userid, keyid, current_session):
"""Delete an API key for a user
Route: ``/users/<userid>/api_keys/<keyid>`` | Method: ``DELETE``
Args:
userid: UserID of user to retrieve
keyid: KeyID of the API key to delete
current_session: Session sent with Authorization Header
Returns:
HTTP-204 or HTTP error
"""
if userid != current_session.user_.userid:
raise Unauthorized
apiKeyController.delete_api_key(keyid)
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,8 +1,22 @@
from functools import wraps from functools import wraps
from werkzeug.exceptions import Unauthorized from werkzeug.exceptions import Unauthorized
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.controller import sessionController from flaschengeist.controller import apiKeyController, sessionController
def extract_api_key(permission=None):
from flask import request
try:
apiKey = request.headers.get("X-API-KEY")
except AttributeError:
logger.debug("Missing X-API-KEY header")
raise Unauthorized
apiKey = apiKeyController.validate_api_key(apiKey, permission)
return apiKey
def extract_session(permission=None): def extract_session(permission=None):
@ -32,7 +46,10 @@ def login_required(permission=None):
def wrap(func): def wrap(func):
@wraps(func) @wraps(func)
def wrapped_f(*args, **kwargs): def wrapped_f(*args, **kwargs):
try:
session = extract_session(permission) session = extract_session(permission)
except Unauthorized:
session = extract_api_key(permission)
kwargs["current_session"] = session kwargs["current_session"] = session
logger.debug("token {{ {} }} is valid".format(session.token)) logger.debug("token {{ {} }} is valid".format(session.token))
return func(*args, **kwargs) return func(*args, **kwargs)

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.2.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, <2.3 #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
@ -38,14 +39,14 @@ install_requires =
[options.extras_require] [options.extras_require]
argon = argon2-cffi argon = argon2-cffi
ldap = flask_ldapconn; ldap3 ldap = flask_ldapconn @ git+https://github.com/rroemhild/flask-ldapconn.git; ldap3
tests = pytest; pytest-depends; coverage tests = pytest; pytest-depends; coverage
mysql = mysql =
PyMySQL;platform_system=='Windows' PyMySQL;platform_system=='Windows'
mysqlclient;platform_system!='Windows' mysqlclient;platform_system!='Windows'
[options.package_data] [options.package_data]
* = *.toml, script.py.mako * = *.toml, script.py.mako, *.ini, */migrations/*, migrations/versions/*
[options.entry_points] [options.entry_points]
console_scripts = console_scripts =