Compare commits

..

6 Commits

23 changed files with 124 additions and 55 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

@ -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}")
@ -97,7 +112,7 @@ def install_plugin(plugin_name: str):
directory /= loc
if directory.exists():
database_upgrade(revision=f"{plugin_name}@head")
db.session.commit()
return plugin

View File

@ -2,6 +2,7 @@ import re
import secrets
from io import BytesIO
from typing import Optional
from sqlalchemy import exc
from sqlalchemy_utils import merge_references
from datetime import datetime, timedelta, timezone
@ -41,15 +42,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])

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,6 @@
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 +21,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):
@ -51,6 +53,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 +62,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 +81,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 +110,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 +121,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):

View File

@ -56,6 +56,7 @@ def service_debit():
class BalancePlugin(Plugin):
# id = "dev.flaschengeist.balance"
models = models
def install(self):
@ -63,6 +64,7 @@ class BalancePlugin(Plugin):
def load(self):
from .routes import blueprint
self.blueprint = blueprint
@plugins_loaded
@ -71,7 +73,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:

View File

@ -147,7 +147,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)

View File

@ -8,6 +8,7 @@ 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"))

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

@ -106,7 +106,7 @@ def frontend(userid, current_session):
raise Forbidden
if request.method == "POST":
if request.content_length > 1024 ** 2:
if request.content_length > 1024**2:
raise BadRequest
current_session.user_.set_attribute("frontend", request.get_json())
return no_content()
@ -218,7 +218,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()

View File

@ -22,7 +22,7 @@ include_package_data = True
python_requires = >=3.10
packages = find:
install_requires =
Flask>=2.2.2
Flask>=2.2.2, <2.3
Pillow>=9.2
flask_cors
flask_migrate>=3.1.0
@ -30,7 +30,8 @@ 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
@ -73,4 +74,4 @@ testpaths = tests
[coverage:run]
branch = True
source = flaschengeist
source = flaschengeist