Compare commits
25 Commits
3da2ed53d5
...
e4b937991b
Author | SHA1 | Date |
---|---|---|
Tim Gröger | e4b937991b | |
Tim Gröger | 526433afba | |
Tim Gröger | 8fb74358e7 | |
Tim Gröger | 26a00ed6a6 | |
Tim Gröger | ff13eefb45 | |
Tim Gröger | 5ef603ee50 | |
Ferdinand Thiessen | 80f06e483b | |
Ferdinand Thiessen | f80ad5c420 | |
Ferdinand Thiessen | 4e1799e297 | |
Ferdinand Thiessen | f7e07fdade | |
Ferdinand Thiessen | 3d833fb6af | |
Ferdinand Thiessen | 2dabd1dd34 | |
Ferdinand Thiessen | cadde543f2 | |
Tim Gröger | 4e46ea1ca3 | |
Tim Gröger | 0d1a39f217 | |
Ferdinand Thiessen | 7129469835 | |
Tim Gröger | e7b978ae3c | |
Tim Gröger | ff1a0544f8 | |
Ferdinand Thiessen | 3fc04c4143 | |
Ferdinand Thiessen | 7928c16c07 | |
Ferdinand Thiessen | 7b5f854d51 | |
Ferdinand Thiessen | 8696699ecb | |
groegert | 776332d5fe | |
Tim Gröger | 96e4e73f4b | |
Tim Gröger | 1e5304fe1e |
|
@ -54,6 +54,8 @@ def __load_plugins(app):
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True
|
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True
|
||||||
)
|
)
|
||||||
|
del plugin
|
||||||
|
continue
|
||||||
if isinstance(plugin, AuthPlugin):
|
if isinstance(plugin, AuthPlugin):
|
||||||
logger.debug(f"Found authentication plugin: {entry_point.name}")
|
logger.debug(f"Found authentication plugin: {entry_point.name}")
|
||||||
if entry_point.name == config["FLASCHENGEIST"]["auth"]:
|
if entry_point.name == config["FLASCHENGEIST"]["auth"]:
|
||||||
|
@ -65,6 +67,7 @@ def __load_plugins(app):
|
||||||
app.config["FG_PLUGINS"][entry_point.name] = plugin
|
app.config["FG_PLUGINS"][entry_point.name] = plugin
|
||||||
if "FG_AUTH_BACKEND" not in app.config:
|
if "FG_AUTH_BACKEND" not in app.config:
|
||||||
logger.error("No authentication plugin configured or authentication plugin not found")
|
logger.error("No authentication plugin configured or authentication plugin not found")
|
||||||
|
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
|
||||||
|
|
||||||
|
|
||||||
def install_all():
|
def install_all():
|
||||||
|
|
|
@ -9,7 +9,7 @@ from flaschengeist import _module_path, logger
|
||||||
|
|
||||||
|
|
||||||
# Default config:
|
# Default config:
|
||||||
config = {"DATABASE": {"port": 3306}}
|
config = {"DATABASE": {"engine": "mysql", "port": 3306}}
|
||||||
|
|
||||||
|
|
||||||
def update_dict(d, u):
|
def update_dict(d, u):
|
||||||
|
@ -65,17 +65,35 @@ def configure_app(app, test_config=None):
|
||||||
else:
|
else:
|
||||||
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
|
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
|
||||||
|
|
||||||
if test_config is None:
|
if test_config is not None:
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql{driver}://{user}:{passwd}@{host}:{port}/{database}".format(
|
config["DATABASE"]["engine"] = "sqlite"
|
||||||
driver="+pymysql" if os.name == "nt" else "",
|
|
||||||
|
if config["DATABASE"]["engine"] == "mysql":
|
||||||
|
engine = "mysql"
|
||||||
|
try:
|
||||||
|
# Try mysqlclient first
|
||||||
|
from MySQLdb import _mysql
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
engine += "+pymysql"
|
||||||
|
options = "?charset=utf8mb4"
|
||||||
|
elif config["DATABASE"]["engine"] == "postgres":
|
||||||
|
engine = "postgresql+psycopg2"
|
||||||
|
options = "?client_encoding=utf8"
|
||||||
|
elif config["DATABASE"]["engine"] == "sqlite":
|
||||||
|
engine = "sqlite"
|
||||||
|
options = ""
|
||||||
|
host = ""
|
||||||
|
else:
|
||||||
|
logger.error(f"Invalid database engine configured. >{config['DATABASE']['engine']}< is unknown")
|
||||||
|
raise Exception
|
||||||
|
if config["DATABASE"]["engine"] in ["mysql", "postgresql"]:
|
||||||
|
host = "{user}:{password}@{host}:{port}".format(
|
||||||
user=config["DATABASE"]["user"],
|
user=config["DATABASE"]["user"],
|
||||||
passwd=config["DATABASE"]["password"],
|
password=config["DATABASE"]["password"],
|
||||||
host=config["DATABASE"]["host"],
|
host=config["DATABASE"]["host"],
|
||||||
database=config["DATABASE"]["database"],
|
|
||||||
port=config["DATABASE"]["port"],
|
port=config["DATABASE"]["port"],
|
||||||
)
|
)
|
||||||
else:
|
app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}"
|
||||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}"
|
|
||||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||||
|
|
||||||
if "root" in config["FLASCHENGEIST"]:
|
if "root" in config["FLASCHENGEIST"]:
|
||||||
|
|
|
@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError
|
||||||
from werkzeug.exceptions import BadRequest, NotFound
|
from werkzeug.exceptions import BadRequest, NotFound
|
||||||
|
|
||||||
from flaschengeist.models.user import Role, Permission
|
from flaschengeist.models.user import Role, Permission
|
||||||
from flaschengeist.database import db
|
from flaschengeist.database import db, case_sensitive
|
||||||
from flaschengeist import logger
|
from flaschengeist import logger
|
||||||
from flaschengeist.utils.hook import Hook
|
from flaschengeist.utils.hook import Hook
|
||||||
|
|
||||||
|
@ -36,8 +36,8 @@ def update_role(role, new_name):
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
logger.debug("IntegrityError: Role might still be in use", exc_info=True)
|
logger.debug("IntegrityError: Role might still be in use", exc_info=True)
|
||||||
raise BadRequest("Role still in use")
|
raise BadRequest("Role still in use")
|
||||||
elif role.name != new_name:
|
else:
|
||||||
if db.session.query(db.exists().where(Role.name == new_name)).scalar():
|
if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar():
|
||||||
raise BadRequest("Name already used")
|
raise BadRequest("Name already used")
|
||||||
role.name = new_name
|
role.name = new_name
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
|
||||||
db = SQLAlchemy()
|
db = SQLAlchemy()
|
||||||
|
|
||||||
|
|
||||||
|
def case_sensitive(s):
|
||||||
|
if db.session.bind.dialect.name == "mysql":
|
||||||
|
from sqlalchemy import func
|
||||||
|
return func.binary(s)
|
||||||
|
return s
|
||||||
|
|
|
@ -20,6 +20,7 @@ secret_key = "V3ryS3cr3t"
|
||||||
level = "WARNING"
|
level = "WARNING"
|
||||||
|
|
||||||
[DATABASE]
|
[DATABASE]
|
||||||
|
# engine = "mysql" (default)
|
||||||
# user = "user"
|
# user = "user"
|
||||||
# host = "127.0.0.1"
|
# host = "127.0.0.1"
|
||||||
# password = "password"
|
# password = "password"
|
||||||
|
@ -28,17 +29,16 @@ level = "WARNING"
|
||||||
[auth_plain]
|
[auth_plain]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
||||||
#[auth_ldap]
|
[auth_ldap]
|
||||||
# enabled = true
|
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
|
||||||
# host =
|
enabled = false
|
||||||
# port =
|
# host = "localhost"
|
||||||
# bind_dn =
|
# port = 389
|
||||||
# base_dn =
|
# base_dn = "dc=example,dc=com"
|
||||||
# secret =
|
# root_dn = "cn=Manager,dc=example,dc=com"
|
||||||
# use_ssl =
|
# root_secret = "SuperS3cret"
|
||||||
# admin_dn =
|
# Uncomment to use secured LDAP (ldaps)
|
||||||
# admin_dn =
|
# use_ssl = true
|
||||||
# default_gid =
|
|
||||||
|
|
||||||
[MESSAGES]
|
[MESSAGES]
|
||||||
welcome_subject = "Welcome to Flaschengeist {name}"
|
welcome_subject = "Welcome to Flaschengeist {name}"
|
||||||
|
|
|
@ -2,7 +2,7 @@ import sys
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from sqlalchemy import BigInteger
|
from sqlalchemy import BigInteger
|
||||||
from sqlalchemy.dialects import mysql
|
from sqlalchemy.dialects import mysql, sqlite
|
||||||
from sqlalchemy.types import DateTime, TypeDecorator
|
from sqlalchemy.types import DateTime, TypeDecorator
|
||||||
|
|
||||||
|
|
||||||
|
@ -44,7 +44,7 @@ class ModelSerializeMixin:
|
||||||
class Serial(TypeDecorator):
|
class Serial(TypeDecorator):
|
||||||
"""Same as MariaDB Serial used for IDs"""
|
"""Same as MariaDB Serial used for IDs"""
|
||||||
|
|
||||||
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql")
|
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite")
|
||||||
|
|
||||||
|
|
||||||
class UtcDateTime(TypeDecorator):
|
class UtcDateTime(TypeDecorator):
|
||||||
|
|
|
@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin):
|
||||||
display_name: str = db.Column(db.String(30))
|
display_name: str = db.Column(db.String(30))
|
||||||
firstname: str = db.Column(db.String(50), nullable=False)
|
firstname: str = db.Column(db.String(50), nullable=False)
|
||||||
lastname: str = db.Column(db.String(50), nullable=False)
|
lastname: str = db.Column(db.String(50), nullable=False)
|
||||||
mail: str = db.Column(db.String(60), nullable=False)
|
mail: str = db.Column(db.String(60))
|
||||||
birthday: Optional[date] = db.Column(db.Date)
|
birthday: Optional[date] = db.Column(db.Date)
|
||||||
roles: list[str] = []
|
roles: list[str] = []
|
||||||
permissions: Optional[list[str]] = None
|
permissions: Optional[list[str]] = None
|
||||||
|
|
|
@ -33,6 +33,7 @@ class Plugin:
|
||||||
|
|
||||||
blueprint = None # You have to override
|
blueprint = None # You have to override
|
||||||
permissions = [] # You have to override
|
permissions = [] # You have to override
|
||||||
|
id = "dev.flaschengeist.plugin" # You have to override
|
||||||
name = "plugin" # You have to override
|
name = "plugin" # You have to override
|
||||||
models = None # You have to override
|
models = None # You have to override
|
||||||
|
|
||||||
|
@ -94,7 +95,7 @@ class Plugin:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
def notify(self, user, text: str, data=None):
|
def notify(self, user, text: str, data=None):
|
||||||
n = Notification(text=text, data=data, plugin=self.name, user_=user)
|
n = Notification(text=text, data=data, plugin=self.id, user_=user)
|
||||||
db.session.add(n)
|
db.session.add(n)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
|
@ -1,56 +1,61 @@
|
||||||
"""LDAP Authentication Provider Plugin"""
|
"""LDAP Authentication Provider Plugin"""
|
||||||
import io
|
import io
|
||||||
|
import os
|
||||||
import ssl
|
import ssl
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from flask_ldapconn import LDAPConn
|
from flask_ldapconn import LDAPConn
|
||||||
from flask import current_app as app
|
from flask import current_app as app
|
||||||
from ldap3.utils.hashed import hashed
|
|
||||||
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
|
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
|
||||||
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5
|
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
|
||||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||||
|
|
||||||
from flaschengeist import logger
|
from flaschengeist import logger
|
||||||
from flaschengeist.plugins import AuthPlugin, after_role_updated
|
from flaschengeist.plugins import AuthPlugin, before_role_updated
|
||||||
from flaschengeist.models.user import User, Role, _Avatar
|
from flaschengeist.models.user import User, Role, _Avatar
|
||||||
import flaschengeist.controller.userController as userController
|
import flaschengeist.controller.userController as userController
|
||||||
|
|
||||||
|
|
||||||
class AuthLDAP(AuthPlugin):
|
class AuthLDAP(AuthPlugin):
|
||||||
def __init__(self, cfg):
|
def __init__(self, config):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
config = {"port": 389, "use_ssl": False}
|
|
||||||
config.update(cfg)
|
|
||||||
app.config.update(
|
app.config.update(
|
||||||
LDAP_SERVER=config["host"],
|
LDAP_SERVER=config.get("host", "localhost"),
|
||||||
LDAP_PORT=config["port"],
|
LDAP_PORT=config.get("port", 389),
|
||||||
LDAP_BINDDN=config["bind_dn"],
|
LDAP_BINDDN=config.get("bind_dn", None),
|
||||||
|
LDAP_SECRET=config.get("secret", None),
|
||||||
|
LDAP_USE_SSL=config.get("use_ssl", False),
|
||||||
|
# That's not TLS, its dirty StartTLS on unencrypted LDAP
|
||||||
LDAP_USE_TLS=False,
|
LDAP_USE_TLS=False,
|
||||||
LDAP_USE_SSL=config["use_ssl"],
|
LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
|
||||||
LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2,
|
|
||||||
LDAP_REQUIRE_CERT=ssl.CERT_NONE,
|
|
||||||
FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
|
FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
|
||||||
)
|
)
|
||||||
if "secret" in config:
|
logger.warning(app.config.get("LDAP_USE_SSL"))
|
||||||
app.config["LDAP_SECRET"] = config["secret"]
|
if "ca_cert" in config:
|
||||||
|
app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"]
|
||||||
|
else:
|
||||||
|
# Default is CERT_REQUIRED
|
||||||
|
app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL
|
||||||
self.ldap = LDAPConn(app)
|
self.ldap = LDAPConn(app)
|
||||||
self.dn = config["base_dn"]
|
self.base_dn = config["base_dn"]
|
||||||
self.default_gid = config["default_gid"]
|
self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn)
|
||||||
|
self.group_dn = config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn)
|
||||||
|
self.password_hash = config.get("password_hash", "SSHA").upper()
|
||||||
|
self.object_classes = config.get("object_classes", ["inetOrgPerson"])
|
||||||
|
self.user_attributes: dict = config.get("user_attributes", {})
|
||||||
|
|
||||||
# TODO: might not be set if modify is called
|
# TODO: might not be set if modify is called
|
||||||
if "admin_dn" in config:
|
self.root_dn = config.get("root_dn", None)
|
||||||
self.admin_dn = config["admin_dn"]
|
self.root_secret = config.get("root_secret", None)
|
||||||
self.admin_secret = config["admin_secret"]
|
|
||||||
else:
|
|
||||||
self.admin_dn = None
|
|
||||||
|
|
||||||
@after_role_updated
|
@before_role_updated
|
||||||
def _role_updated(role, new_name):
|
def _role_updated(role, new_name):
|
||||||
|
logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})")
|
||||||
self.__modify_role(role, new_name)
|
self.__modify_role(role, new_name)
|
||||||
|
|
||||||
def login(self, user, password):
|
def login(self, user, password):
|
||||||
if not user:
|
if not user:
|
||||||
return False
|
return False
|
||||||
return self.ldap.authenticate(user.userid, password, "uid", self.dn)
|
return self.ldap.authenticate(user.userid, password, "uid", self.base_dn)
|
||||||
|
|
||||||
def find_user(self, userid, mail=None):
|
def find_user(self, userid, mail=None):
|
||||||
attr = self.__find(userid, mail)
|
attr = self.__find(userid, mail)
|
||||||
|
@ -64,42 +69,41 @@ class AuthLDAP(AuthPlugin):
|
||||||
self.__update(user, attr)
|
self.__update(user, attr)
|
||||||
|
|
||||||
def create_user(self, user, password):
|
def create_user(self, user, password):
|
||||||
if self.admin_dn is None:
|
if self.root_dn is None:
|
||||||
logger.error("admin_dn missing in ldap config!")
|
logger.error("root_dn missing in ldap config!")
|
||||||
raise InternalServerError
|
raise InternalServerError
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||||
self.ldap.connection.search(
|
attributes = self.user_attributes.copy()
|
||||||
"ou=user,{}".format(self.dn),
|
if "uidNumber" in attributes:
|
||||||
"(uidNumber=*)",
|
self.ldap.connection.search(
|
||||||
SUBTREE,
|
self.search_dn,
|
||||||
attributes=["uidNumber"],
|
"(uidNumber=*)",
|
||||||
|
SUBTREE,
|
||||||
|
attributes=["uidNumber"],
|
||||||
|
)
|
||||||
|
resp = sorted(
|
||||||
|
self.ldap.response(),
|
||||||
|
key=lambda i: i["attributes"]["uidNumber"],
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
|
||||||
|
dn = self.dn_template.format(
|
||||||
|
firstname=user.firstname,
|
||||||
|
lastname=user.lastname,
|
||||||
|
userid=user.userid,
|
||||||
|
mail=user.mail,
|
||||||
|
display_name=user.display_name,
|
||||||
|
base_dn=self.base_dn,
|
||||||
)
|
)
|
||||||
uid_number = (
|
attributes.update({
|
||||||
sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][
|
"sn": user.lastname,
|
||||||
"attributes"
|
"givenName": user.firstname,
|
||||||
]["uidNumber"]
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}"
|
|
||||||
object_class = [
|
|
||||||
"inetOrgPerson",
|
|
||||||
"posixAccount",
|
|
||||||
"person",
|
|
||||||
"organizationalPerson",
|
|
||||||
]
|
|
||||||
attributes = {
|
|
||||||
"sn": user.firstname,
|
|
||||||
"givenName": user.lastname,
|
|
||||||
"gidNumber": self.default_gid,
|
|
||||||
"homeDirectory": f"/home/{user.userid}",
|
|
||||||
"loginShell": "/bin/bash",
|
|
||||||
"uid": user.userid,
|
"uid": user.userid,
|
||||||
"userPassword": hashed(HASHED_SALTED_MD5, password),
|
"userPassword": self.__hash(password),
|
||||||
"uidNumber": uid_number,
|
})
|
||||||
}
|
ldap_conn.add(dn, self.object_classes, attributes)
|
||||||
ldap_conn.add(dn, object_class, attributes)
|
|
||||||
self._set_roles(user)
|
self._set_roles(user)
|
||||||
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
||||||
raise BadRequest
|
raise BadRequest
|
||||||
|
@ -110,10 +114,10 @@ class AuthLDAP(AuthPlugin):
|
||||||
if password:
|
if password:
|
||||||
ldap_conn = self.ldap.connect(dn, password)
|
ldap_conn = self.ldap.connect(dn, password)
|
||||||
else:
|
else:
|
||||||
if self.admin_dn is None:
|
if self.root_dn is None:
|
||||||
logger.error("admin_dn missing in ldap config!")
|
logger.error("root_dn missing in ldap config!")
|
||||||
raise InternalServerError
|
raise InternalServerError
|
||||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||||
modifier = {}
|
modifier = {}
|
||||||
for name, ldap_name in [
|
for name, ldap_name in [
|
||||||
("firstname", "givenName"),
|
("firstname", "givenName"),
|
||||||
|
@ -124,9 +128,7 @@ class AuthLDAP(AuthPlugin):
|
||||||
if hasattr(user, name):
|
if hasattr(user, name):
|
||||||
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
|
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
|
||||||
if new_password:
|
if new_password:
|
||||||
# TODO: Use secure hash!
|
modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
|
||||||
salted_password = hashed(HASHED_SALTED_MD5, new_password)
|
|
||||||
modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])]
|
|
||||||
ldap_conn.modify(dn, modifier)
|
ldap_conn.modify(dn, modifier)
|
||||||
self._set_roles(user)
|
self._set_roles(user)
|
||||||
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
||||||
|
@ -134,7 +136,7 @@ class AuthLDAP(AuthPlugin):
|
||||||
|
|
||||||
def get_avatar(self, user):
|
def get_avatar(self, user):
|
||||||
self.ldap.connection.search(
|
self.ldap.connection.search(
|
||||||
"ou=user,{}".format(self.dn),
|
self.search_dn,
|
||||||
"(uid={})".format(user.userid),
|
"(uid={})".format(user.userid),
|
||||||
SUBTREE,
|
SUBTREE,
|
||||||
attributes=["jpegPhoto"],
|
attributes=["jpegPhoto"],
|
||||||
|
@ -150,8 +152,8 @@ class AuthLDAP(AuthPlugin):
|
||||||
raise NotFound
|
raise NotFound
|
||||||
|
|
||||||
def set_avatar(self, user, avatar: _Avatar):
|
def set_avatar(self, user, avatar: _Avatar):
|
||||||
if self.admin_dn is None:
|
if self.root_dn is None:
|
||||||
logger.error("admin_dn missing in ldap config!")
|
logger.error("root_dn missing in ldap config!")
|
||||||
raise InternalServerError
|
raise InternalServerError
|
||||||
|
|
||||||
if avatar.mimetype != "image/jpeg":
|
if avatar.mimetype != "image/jpeg":
|
||||||
|
@ -172,16 +174,16 @@ class AuthLDAP(AuthPlugin):
|
||||||
raise BadRequest("Unsupported image format")
|
raise BadRequest("Unsupported image format")
|
||||||
|
|
||||||
dn = user.get_attribute("DN")
|
dn = user.get_attribute("DN")
|
||||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||||
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
|
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
|
||||||
|
|
||||||
def __find(self, userid, mail=None):
|
def __find(self, userid, mail=None):
|
||||||
"""Find attributes of an user by uid or mail in LDAP"""
|
"""Find attributes of an user by uid or mail in LDAP"""
|
||||||
con = self.ldap.connection
|
con = self.ldap.connection
|
||||||
if not con:
|
if not con:
|
||||||
con = self.ldap.connect(self.admin_dn, self.admin_secret)
|
con = self.ldap.connect(self.root_dn, self.root_secret)
|
||||||
con.search(
|
con.search(
|
||||||
f"ou=user,{self.dn}",
|
self.search_dn,
|
||||||
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
|
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
|
||||||
SUBTREE,
|
SUBTREE,
|
||||||
attributes=["uid", "givenName", "sn", "mail"],
|
attributes=["uid", "givenName", "sn", "mail"],
|
||||||
|
@ -205,12 +207,12 @@ class AuthLDAP(AuthPlugin):
|
||||||
role: Role,
|
role: Role,
|
||||||
new_name: Optional[str],
|
new_name: Optional[str],
|
||||||
):
|
):
|
||||||
if self.admin_dn is None:
|
if self.root_dn is None:
|
||||||
logger.error("admin_dn missing in ldap config!")
|
logger.error("root_dn missing in ldap config!")
|
||||||
raise InternalServerError
|
raise InternalServerError
|
||||||
try:
|
try:
|
||||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||||
ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"])
|
ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"])
|
||||||
if len(ldap_conn.response) > 0:
|
if len(ldap_conn.response) > 0:
|
||||||
dn = ldap_conn.response[0]["dn"]
|
dn = ldap_conn.response[0]["dn"]
|
||||||
if new_name:
|
if new_name:
|
||||||
|
@ -218,13 +220,33 @@ class AuthLDAP(AuthPlugin):
|
||||||
else:
|
else:
|
||||||
ldap_conn.delete(dn)
|
ldap_conn.delete(dn)
|
||||||
|
|
||||||
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
except LDAPPasswordIsMandatoryError:
|
||||||
raise BadRequest
|
raise BadRequest
|
||||||
|
except LDAPBindError:
|
||||||
|
logger.debug(f"Could not bind to LDAP server", exc_info=True)
|
||||||
|
raise InternalServerError
|
||||||
|
|
||||||
|
def __hash(self, password):
|
||||||
|
if self.password_hash == "ARGON2":
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
|
||||||
|
return f"{{ARGON2}}{PasswordHasher().hash(password)}"
|
||||||
|
else:
|
||||||
|
from hashlib import pbkdf2_hmac, sha1
|
||||||
|
import base64
|
||||||
|
|
||||||
|
salt = os.urandom(16)
|
||||||
|
if self.password_hash == "PBKDF2":
|
||||||
|
rounds = 200000
|
||||||
|
password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode()
|
||||||
|
return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}"
|
||||||
|
else:
|
||||||
|
return f"{{SSHA}}{base64.b64encode(sha1(password + salt) + salt)}"
|
||||||
|
|
||||||
def _get_groups(self, uid):
|
def _get_groups(self, uid):
|
||||||
groups = []
|
groups = []
|
||||||
self.ldap.connection.search(
|
self.ldap.connection.search(
|
||||||
"ou=group,{}".format(self.dn),
|
self.group_dn,
|
||||||
"(memberUID={})".format(uid),
|
"(memberUID={})".format(uid),
|
||||||
SUBTREE,
|
SUBTREE,
|
||||||
attributes=["cn"],
|
attributes=["cn"],
|
||||||
|
@ -236,7 +258,7 @@ class AuthLDAP(AuthPlugin):
|
||||||
|
|
||||||
def _get_all_roles(self):
|
def _get_all_roles(self):
|
||||||
self.ldap.connection.search(
|
self.ldap.connection.search(
|
||||||
f"ou=group,{self.dn}",
|
self.group_dn,
|
||||||
"(cn=*)",
|
"(cn=*)",
|
||||||
SUBTREE,
|
SUBTREE,
|
||||||
attributes=["cn", "gidNumber", "memberUid"],
|
attributes=["cn", "gidNumber", "memberUid"],
|
||||||
|
@ -245,8 +267,7 @@ class AuthLDAP(AuthPlugin):
|
||||||
|
|
||||||
def _set_roles(self, user: User):
|
def _set_roles(self, user: User):
|
||||||
try:
|
try:
|
||||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||||
|
|
||||||
ldap_roles = self._get_all_roles()
|
ldap_roles = self._get_all_roles()
|
||||||
|
|
||||||
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
|
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
|
||||||
|
@ -255,7 +276,7 @@ class AuthLDAP(AuthPlugin):
|
||||||
for user_role in user.roles:
|
for user_role in user.roles:
|
||||||
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
|
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
|
||||||
ldap_conn.add(
|
ldap_conn.add(
|
||||||
f"cn={user_role},ou=group,{self.dn}",
|
f"cn={user_role},{self.group_dn}",
|
||||||
["posixGroup"],
|
["posixGroup"],
|
||||||
attributes={"gidNumber": gid_number},
|
attributes={"gidNumber": gid_number},
|
||||||
)
|
)
|
||||||
|
|
|
@ -113,7 +113,9 @@ def get_transaction(transaction_id) -> Transaction:
|
||||||
return transaction
|
return transaction
|
||||||
|
|
||||||
|
|
||||||
def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True):
|
def get_transactions(
|
||||||
|
user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False
|
||||||
|
):
|
||||||
count = None
|
count = None
|
||||||
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))
|
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))
|
||||||
if start:
|
if start:
|
||||||
|
@ -125,7 +127,10 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None, show_r
|
||||||
query = query.filter(Transaction.original_ == None)
|
query = query.filter(Transaction.original_ == None)
|
||||||
if not show_cancelled:
|
if not show_cancelled:
|
||||||
query = query.filter(Transaction.reversal_id.is_(None))
|
query = query.filter(Transaction.reversal_id.is_(None))
|
||||||
query = query.order_by(Transaction.time)
|
if descending:
|
||||||
|
query = query.order_by(Transaction.time.desc())
|
||||||
|
else:
|
||||||
|
query = query.order_by(Transaction.time)
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
count = query.count()
|
count = query.count()
|
||||||
query = query.limit(limit)
|
query = query.limit(limit)
|
||||||
|
|
|
@ -99,6 +99,32 @@ def set_limit(userid, current_session: Session):
|
||||||
return HTTP.no_content()
|
return HTTP.no_content()
|
||||||
|
|
||||||
|
|
||||||
|
@BalancePlugin.blueprint.route("/users/balance/limit", methods=["GET", "PUT"])
|
||||||
|
@login_required(permission=permissions.SET_LIMIT)
|
||||||
|
def limits(current_session: Session):
|
||||||
|
"""Get, Modify limit of all users
|
||||||
|
|
||||||
|
Args:
|
||||||
|
current_ession: Session sent with Authorization Header
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON encoded array of userid with limit or HTTP-error
|
||||||
|
"""
|
||||||
|
|
||||||
|
users = userController.get_users()
|
||||||
|
if request.method == "GET":
|
||||||
|
return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users])
|
||||||
|
|
||||||
|
data = request.get_json()
|
||||||
|
try:
|
||||||
|
limit = data["limit"]
|
||||||
|
except (TypeError, KeyError):
|
||||||
|
raise BadRequest
|
||||||
|
for user in users:
|
||||||
|
balance_controller.set_limit(user, limit)
|
||||||
|
return HTTP.no_content()
|
||||||
|
|
||||||
|
|
||||||
@BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["GET"])
|
@BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["GET"])
|
||||||
@login_required(permission=permissions.SHOW)
|
@login_required(permission=permissions.SHOW)
|
||||||
def get_balance(userid, current_session: Session):
|
def get_balance(userid, current_session: Session):
|
||||||
|
@ -170,6 +196,7 @@ def get_transactions(userid, current_session: Session):
|
||||||
show_cancelled = request.args.get("showCancelled", True)
|
show_cancelled = request.args.get("showCancelled", True)
|
||||||
limit = request.args.get("limit")
|
limit = request.args.get("limit")
|
||||||
offset = request.args.get("offset")
|
offset = request.args.get("offset")
|
||||||
|
descending = request.args.get("descending", False)
|
||||||
try:
|
try:
|
||||||
if limit is not None:
|
if limit is not None:
|
||||||
limit = int(limit)
|
limit = int(limit)
|
||||||
|
@ -179,11 +206,20 @@ def get_transactions(userid, current_session: Session):
|
||||||
show_reversals = str2bool(show_reversals)
|
show_reversals = str2bool(show_reversals)
|
||||||
if not isinstance(show_cancelled, bool):
|
if not isinstance(show_cancelled, bool):
|
||||||
show_cancelled = str2bool(show_cancelled)
|
show_cancelled = str2bool(show_cancelled)
|
||||||
|
if not isinstance(descending, bool):
|
||||||
|
descending = str2bool(descending)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise BadRequest
|
raise BadRequest
|
||||||
|
|
||||||
transactions, count = balance_controller.get_transactions(
|
transactions, count = balance_controller.get_transactions(
|
||||||
user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled
|
user,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
show_reversal=show_reversals,
|
||||||
|
show_cancelled=show_cancelled,
|
||||||
|
descending=descending,
|
||||||
)
|
)
|
||||||
return {"transactions": transactions, "count": count}
|
return {"transactions": transactions, "count": count}
|
||||||
|
|
||||||
|
|
|
@ -11,6 +11,7 @@ from . import permissions, models
|
||||||
|
|
||||||
class EventPlugin(Plugin):
|
class EventPlugin(Plugin):
|
||||||
name = "events"
|
name = "events"
|
||||||
|
id = "dev.flaschengeist.events"
|
||||||
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name])
|
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name])
|
||||||
permissions = permissions.permissions
|
permissions = permissions.permissions
|
||||||
blueprint = Blueprint(name, __name__)
|
blueprint = Blueprint(name, __name__)
|
||||||
|
|
|
@ -116,7 +116,7 @@ def get_event(event_id, with_backup=False) -> Event:
|
||||||
if event is None:
|
if event is None:
|
||||||
raise NotFound
|
raise NotFound
|
||||||
if not with_backup:
|
if not with_backup:
|
||||||
return clear_backup(event)
|
clear_backup(event)
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
@ -153,7 +153,9 @@ def delete_event(event_id):
|
||||||
Raises:
|
Raises:
|
||||||
NotFound if not found
|
NotFound if not found
|
||||||
"""
|
"""
|
||||||
event = get_event(event_id)
|
event = get_event(event_id, True)
|
||||||
|
for job in event.jobs:
|
||||||
|
delete_job(job)
|
||||||
db.session.delete(event)
|
db.session.delete(event)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -201,25 +203,42 @@ def update():
|
||||||
|
|
||||||
|
|
||||||
def delete_job(job: Job):
|
def delete_job(job: Job):
|
||||||
|
for service in job.services:
|
||||||
|
unassign_job(service=service, notify=True)
|
||||||
db.session.delete(job)
|
db.session.delete(job)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def assign_to_job(job: Job, user, value):
|
def assign_job(job: Job, user, value):
|
||||||
|
assert value > 0
|
||||||
service = Service.query.get((job.id, user.id_))
|
service = Service.query.get((job.id, user.id_))
|
||||||
if value < 0:
|
if service:
|
||||||
if not service:
|
service.value = value
|
||||||
raise BadRequest
|
|
||||||
db.session.delete(service)
|
|
||||||
else:
|
else:
|
||||||
if service:
|
service = Service(user_=user, value=value, job_=job)
|
||||||
service.value = value
|
db.session.add(service)
|
||||||
else:
|
|
||||||
service = Service(user_=user, value=value, job_=job)
|
|
||||||
db.session.add(service)
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def unassign_job(job: Job = None, user=None, service=None, notify=False):
|
||||||
|
if service is None:
|
||||||
|
assert(job is not None and user is not None)
|
||||||
|
service = Service.query.get((job.id, user.id_))
|
||||||
|
else:
|
||||||
|
user = service.user_
|
||||||
|
if not service:
|
||||||
|
raise BadRequest
|
||||||
|
|
||||||
|
event_id = service.job_.event_id_
|
||||||
|
|
||||||
|
db.session.delete(service)
|
||||||
|
db.session.commit()
|
||||||
|
if notify:
|
||||||
|
EventPlugin.plugin.notify(
|
||||||
|
user, "Your assignmet was cancelled", {"event_id": event_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@scheduled
|
@scheduled
|
||||||
def assign_backups():
|
def assign_backups():
|
||||||
logger.debug("Notifications")
|
logger.debug("Notifications")
|
||||||
|
|
|
@ -415,7 +415,10 @@ def update_job(event_id, job_id, current_session: Session):
|
||||||
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
|
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
|
||||||
):
|
):
|
||||||
raise Forbidden
|
raise Forbidden
|
||||||
event_controller.assign_to_job(job, user, value)
|
if value > 0:
|
||||||
|
event_controller.assign_job(job, user, value)
|
||||||
|
else:
|
||||||
|
event_controller.unassign_job(job, user, notify=user != current_session.user_)
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
raise BadRequest
|
raise BadRequest
|
||||||
|
|
||||||
|
|
|
@ -214,10 +214,37 @@ def get_drinks(identifier=None):
|
||||||
|
|
||||||
if identifier:
|
if identifier:
|
||||||
result = pricelist_controller.get_drink(identifier, public=public)
|
result = pricelist_controller.get_drink(identifier, public=public)
|
||||||
|
return jsonify(result)
|
||||||
else:
|
else:
|
||||||
result = pricelist_controller.get_drinks(public=public)
|
limit = request.args.get("limit")
|
||||||
logger.debug(f"GET drink {result}")
|
offset = request.args.get("offset")
|
||||||
return jsonify(result)
|
search_name = request.args.get("search_name")
|
||||||
|
search_key = request.args.get("search_key")
|
||||||
|
ingredient = request.args.get("ingredient", type=bool)
|
||||||
|
receipt = request.args.get("receipt", type=bool)
|
||||||
|
try:
|
||||||
|
if limit is not None:
|
||||||
|
limit = int(limit)
|
||||||
|
if offset is not None:
|
||||||
|
offset = int(offset)
|
||||||
|
if ingredient is not None:
|
||||||
|
ingredient = bool(ingredient)
|
||||||
|
if receipt is not None:
|
||||||
|
receipt = bool(receipt)
|
||||||
|
except ValueError:
|
||||||
|
raise BadRequest
|
||||||
|
drinks, count = pricelist_controller.get_drinks(
|
||||||
|
public=public,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
search_name=search_name,
|
||||||
|
search_key=search_key,
|
||||||
|
ingredient=ingredient,
|
||||||
|
receipt=receipt,
|
||||||
|
)
|
||||||
|
logger.debug(f"GET drink {drinks}, {count}")
|
||||||
|
# return jsonify({"drinks": drinks, "count": count})
|
||||||
|
return jsonify({"drinks": drinks, "count": count})
|
||||||
|
|
||||||
|
|
||||||
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
|
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
|
||||||
|
@ -567,6 +594,7 @@ def get_columns(userid, current_session):
|
||||||
userController.persist()
|
userController.persist()
|
||||||
return no_content()
|
return no_content()
|
||||||
|
|
||||||
|
|
||||||
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
|
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
|
||||||
@login_required()
|
@login_required()
|
||||||
def get_columns_order(userid, current_session):
|
def get_columns_order(userid, current_session):
|
||||||
|
|
|
@ -76,16 +76,17 @@ class DrinkIngredient(db.Model, ModelSerializeMixin):
|
||||||
id: int = db.Column("id", db.Integer, primary_key=True)
|
id: int = db.Column("id", db.Integer, primary_key=True)
|
||||||
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
|
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
|
||||||
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id"))
|
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id"))
|
||||||
# drink_ingredient: Drink = db.relationship("Drink")
|
cost_per_volume: float
|
||||||
# price: float = 0
|
name: str
|
||||||
|
_drink_ingredient: Drink = db.relationship("Drink")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cost_per_volume(self):
|
||||||
|
return self._drink_ingredient.cost_per_volume if self._drink_ingredient else None
|
||||||
|
|
||||||
# @property
|
@property
|
||||||
# def price(self):
|
def name(self):
|
||||||
# try:
|
return self._drink_ingredient.name if self._drink_ingredient else None
|
||||||
# return self.drink_ingredient.cost_price_pro_volume * self.volume
|
|
||||||
# except AttributeError:
|
|
||||||
# pass
|
|
||||||
|
|
||||||
|
|
||||||
class Ingredient(db.Model, ModelSerializeMixin):
|
class Ingredient(db.Model, ModelSerializeMixin):
|
||||||
|
|
|
@ -131,13 +131,44 @@ def _create_public_drink(drink):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_drinks(name=None, public=False):
|
def get_drinks(
|
||||||
|
name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False, receipt=None
|
||||||
|
):
|
||||||
|
count = None
|
||||||
if name:
|
if name:
|
||||||
drinks = Drink.query.filter(Drink.name.contains(name)).all()
|
query = Drink.query.filter(Drink.name.contains(name))
|
||||||
drinks = Drink.query.all()
|
else:
|
||||||
|
query = Drink.query
|
||||||
|
if ingredient:
|
||||||
|
query = query.filter(Drink.cost_per_volume >= 0)
|
||||||
|
if receipt:
|
||||||
|
query = query.filter(Drink.volumes.any(DrinkPriceVolume.ingredients != None))
|
||||||
|
if search_name:
|
||||||
|
if search_key == "name":
|
||||||
|
query = query.filter(Drink.name.contains(search_name))
|
||||||
|
elif search_key == "article_id":
|
||||||
|
query = query.filter(Drink.article_id.contains(search_name))
|
||||||
|
elif search_key == "drink_type":
|
||||||
|
query = query.filter(Drink.type.has(DrinkType.name.contains(search_name)))
|
||||||
|
elif search_key == "tags":
|
||||||
|
query = query.filter(Drink.tags.any(Tag.name.contains(search_name)))
|
||||||
|
else:
|
||||||
|
query = query.filter(
|
||||||
|
(Drink.name.contains(search_name))
|
||||||
|
| (Drink.article_id.contains(search_name))
|
||||||
|
| (Drink.type.has(DrinkType.name.contains(search_name)))
|
||||||
|
| (Drink.tags.any(Tag.name.contains(search_name)))
|
||||||
|
)
|
||||||
|
|
||||||
|
if limit is not None:
|
||||||
|
count = query.count()
|
||||||
|
query = query.limit(limit)
|
||||||
|
if offset is not None:
|
||||||
|
query = query.offset(offset)
|
||||||
|
drinks = query.all()
|
||||||
if public:
|
if public:
|
||||||
return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)]
|
return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)], count
|
||||||
return drinks
|
return drinks, count
|
||||||
|
|
||||||
|
|
||||||
def get_drink(identifier, public=False):
|
def get_drink(identifier, public=False):
|
||||||
|
@ -209,6 +240,9 @@ def set_volumes(volumes):
|
||||||
|
|
||||||
def delete_drink(identifier):
|
def delete_drink(identifier):
|
||||||
drink = get_drink(identifier)
|
drink = get_drink(identifier)
|
||||||
|
if drink.uuid:
|
||||||
|
path = config["pricelist"]["path"]
|
||||||
|
delete_picture(f"{path}/{drink.uuid}")
|
||||||
db.session.delete(drink)
|
db.session.delete(drink)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
@ -315,6 +349,10 @@ def delete_price(identifier):
|
||||||
def set_drink_ingredient(data):
|
def set_drink_ingredient(data):
|
||||||
allowed_keys = DrinkIngredient().serialize().keys()
|
allowed_keys = DrinkIngredient().serialize().keys()
|
||||||
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}
|
||||||
|
if "cost_per_volume" in values:
|
||||||
|
values.pop("cost_per_volume")
|
||||||
|
if "name" in values:
|
||||||
|
values.pop("name")
|
||||||
ingredient_id = values.pop("id", -1)
|
ingredient_id = values.pop("id", -1)
|
||||||
if ingredient_id < 0:
|
if ingredient_id < 0:
|
||||||
drink_ingredient = DrinkIngredient(**values)
|
drink_ingredient = DrinkIngredient(**values)
|
||||||
|
|
|
@ -231,3 +231,21 @@ def notifications(current_session):
|
||||||
def remove_notifications(nid, current_session):
|
def remove_notifications(nid, current_session):
|
||||||
userController.delete_notification(nid, current_session.user_)
|
userController.delete_notification(nid, current_session.user_)
|
||||||
return no_content()
|
return no_content()
|
||||||
|
|
||||||
|
|
||||||
|
@UsersPlugin.blueprint.route("/users/<userid>/shortcuts", methods=["GET", "PUT"])
|
||||||
|
@login_required()
|
||||||
|
def shortcuts(userid, current_session):
|
||||||
|
if userid != current_session.user_.userid:
|
||||||
|
raise Forbidden
|
||||||
|
|
||||||
|
user = userController.get_user(userid)
|
||||||
|
if request.method == "GET":
|
||||||
|
return jsonify(user.get_attribute("users_link_shortcuts", []))
|
||||||
|
else:
|
||||||
|
data = request.get_json()
|
||||||
|
if not isinstance(data, list) or not all(isinstance(n, dict) for n in data):
|
||||||
|
raise BadRequest
|
||||||
|
user.set_attribute("users_link_shortcuts", data)
|
||||||
|
userController.persist()
|
||||||
|
return no_content()
|
||||||
|
|
114
readme.md
114
readme.md
|
@ -1,9 +1,16 @@
|
||||||
# Flaschengeist
|
# Flaschengeist
|
||||||
|
This is the backend of the Flaschengeist.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
### Requirements
|
### Requirements
|
||||||
- mysql or mariadb
|
- `mysql` or `mariadb`
|
||||||
- python 3.6+
|
- maybe `libmariadb` development files[1]
|
||||||
|
- python 3.7+
|
||||||
|
|
||||||
|
[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on
|
||||||
|
Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the
|
||||||
|
development files for `libmariadb` to be present on your system.
|
||||||
|
|
||||||
### Install python files
|
### Install python files
|
||||||
pip3 install --user .
|
pip3 install --user .
|
||||||
or with ldap support
|
or with ldap support
|
||||||
|
@ -30,9 +37,21 @@ Configuration is done within the a `flaschengeist.toml`file, you can copy the on
|
||||||
1. `~/.config/`
|
1. `~/.config/`
|
||||||
2. A custom path and set environment variable `FLASCHENGEIST_CONF`
|
2. A custom path and set environment variable `FLASCHENGEIST_CONF`
|
||||||
|
|
||||||
Change at least the database parameters!
|
Uncomment and change at least all the database parameters!
|
||||||
|
|
||||||
### Database installation
|
### Database installation
|
||||||
|
The user needs to have full permissions to the database.
|
||||||
|
If not you need to create user and database manually do (or similar on Windows):
|
||||||
|
|
||||||
|
(
|
||||||
|
echo "CREATE DATABASE flaschengeist;"
|
||||||
|
echo "CREATE USER 'flaschengeist'@'localhost' IDENTIFIED BY 'flaschengeist';"
|
||||||
|
echo "GRANT ALL PRIVILEGES ON 'flaschengeist'.* TO 'flaschengeist'@'localhost';"
|
||||||
|
echo "FLUSH PRIVILEGES;"
|
||||||
|
) | sudo mysql
|
||||||
|
|
||||||
|
Then you can install the database tables and initial entries:
|
||||||
|
|
||||||
run_flaschengeist install
|
run_flaschengeist install
|
||||||
|
|
||||||
### Run
|
### Run
|
||||||
|
@ -41,6 +60,8 @@ or with debug messages:
|
||||||
|
|
||||||
run_flaschengeist run --debug
|
run_flaschengeist run --debug
|
||||||
|
|
||||||
|
This will run the backend on http://localhost:5000
|
||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
$ pip install '.[test]'
|
$ pip install '.[test]'
|
||||||
$ pytest
|
$ pytest
|
||||||
|
@ -53,89 +74,4 @@ Or with html output (open `htmlcov/index.html` in a browser):
|
||||||
$ coverage html
|
$ coverage html
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
### Code Style
|
Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).
|
||||||
We enforce you to use PEP 8 code style with a line length of 120 as used by Black.
|
|
||||||
See also [Black Code Style](https://github.com/psf/black/blob/master/docs/the_black_code_style.md).
|
|
||||||
|
|
||||||
#### Code formatting
|
|
||||||
We use [Black](https://github.com/psf/black) as the code formatter.
|
|
||||||
|
|
||||||
Installation:
|
|
||||||
|
|
||||||
pip install black
|
|
||||||
Usage:
|
|
||||||
|
|
||||||
black -l 120 DIRECTORY_OR_FILE
|
|
||||||
|
|
||||||
### Misc
|
|
||||||
#### Git blame
|
|
||||||
When using `git blame` use this to ignore the code formatting commits:
|
|
||||||
|
|
||||||
$ git blame FILE.py --ignore-revs-file .git-blame-ignore-revs
|
|
||||||
Or if you just want to use `git blame`, configure git like this:
|
|
||||||
|
|
||||||
$ git config blame.ignoreRevsFile .git-blame-ignore-revs
|
|
||||||
|
|
||||||
#### Ignore changes on config
|
|
||||||
git update-index --assume-unchanged flaschengeist/flaschengeist.toml
|
|
||||||
|
|
||||||
## Plugin Development
|
|
||||||
### File Structure
|
|
||||||
flaschengeist-example-plugin
|
|
||||||
|> __init__.py
|
|
||||||
|> model.py
|
|
||||||
|> setup.py
|
|
||||||
|
|
||||||
### Files
|
|
||||||
#### \_\_init\_\_.py
|
|
||||||
from flask import Blueprint
|
|
||||||
from flaschengeist.modules import Plugin
|
|
||||||
|
|
||||||
|
|
||||||
example_bp = Blueprint("example", __name__, url_prefix="/example")
|
|
||||||
permissions = ["example_hello"]
|
|
||||||
|
|
||||||
class PluginExample(Plugin):
|
|
||||||
def __init__(self, conf):
|
|
||||||
super().__init__(blueprint=example_bp, permissions=permissions)
|
|
||||||
|
|
||||||
def install(self):
|
|
||||||
from flaschengeist.system.database import db
|
|
||||||
import .model
|
|
||||||
db.create_all()
|
|
||||||
db.session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
@example_bp.route("/hello", methods=['GET'])
|
|
||||||
@login_required(roles=['example_hello'])
|
|
||||||
def __hello(id, **kwargs):
|
|
||||||
return "Hello"
|
|
||||||
|
|
||||||
#### model.py
|
|
||||||
Optional, only needed if you need your own models (database)
|
|
||||||
|
|
||||||
from flaschengeist.system.database import db
|
|
||||||
|
|
||||||
|
|
||||||
class ExampleModel(db.Model):
|
|
||||||
"""Example Model"""
|
|
||||||
__tablename__ = 'example'
|
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
|
||||||
description = db.Column(db.String(240))
|
|
||||||
|
|
||||||
#### setup.py
|
|
||||||
from setuptools import setup, find_packages
|
|
||||||
|
|
||||||
setup(
|
|
||||||
name="flaschengeist-example-plugin",
|
|
||||||
version="0.0.0-dev",
|
|
||||||
packages=find_packages(),
|
|
||||||
install_requires=[
|
|
||||||
"flaschengeist >= 2",
|
|
||||||
],
|
|
||||||
entry_points={
|
|
||||||
"flaschengeist.plugin": [
|
|
||||||
"example = flaschengeist-example-plugin:ExampleModel"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
)
|
|
|
@ -17,7 +17,7 @@ class PrefixMiddleware(object):
|
||||||
def __call__(self, environ, start_response):
|
def __call__(self, environ, start_response):
|
||||||
|
|
||||||
if environ["PATH_INFO"].startswith(self.prefix):
|
if environ["PATH_INFO"].startswith(self.prefix):
|
||||||
environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix):]
|
environ["PATH_INFO"] = environ["PATH_INFO"][len(self.prefix) :]
|
||||||
environ["SCRIPT_NAME"] = self.prefix
|
environ["SCRIPT_NAME"] = self.prefix
|
||||||
return self.app(environ, start_response)
|
return self.app(environ, start_response)
|
||||||
else:
|
else:
|
||||||
|
@ -83,19 +83,19 @@ class InterfaceGenerator:
|
||||||
import typing
|
import typing
|
||||||
|
|
||||||
if (
|
if (
|
||||||
inspect.ismodule(module[1])
|
inspect.ismodule(module[1])
|
||||||
and module[1].__name__.startswith(self.basename)
|
and module[1].__name__.startswith(self.basename)
|
||||||
and module[1].__name__ not in self.known
|
and module[1].__name__ not in self.known
|
||||||
):
|
):
|
||||||
self.known.append(module[1].__name__)
|
self.known.append(module[1].__name__)
|
||||||
for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)):
|
for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)):
|
||||||
self.walker(cls)
|
self.walker(cls)
|
||||||
elif (
|
elif (
|
||||||
inspect.isclass(module[1])
|
inspect.isclass(module[1])
|
||||||
and module[1].__module__.startswith(self.basename)
|
and module[1].__module__.startswith(self.basename)
|
||||||
and module[0] not in self.classes
|
and module[0] not in self.classes
|
||||||
and not module[0].startswith("_")
|
and not module[0].startswith("_")
|
||||||
and hasattr(module[1], "__annotations__")
|
and hasattr(module[1], "__annotations__")
|
||||||
):
|
):
|
||||||
self.this_type = module[0]
|
self.this_type = module[0]
|
||||||
print("\n\n" + module[0] + "\n")
|
print("\n\n" + module[0] + "\n")
|
||||||
|
@ -156,12 +156,14 @@ def export(arguments):
|
||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
gen = InterfaceGenerator(arguments.namespace, arguments.file)
|
gen = InterfaceGenerator(arguments.namespace, arguments.file)
|
||||||
gen.run(models)
|
if not arguments.no_core:
|
||||||
if arguments.plugins:
|
gen.run(models)
|
||||||
|
if arguments.plugins is not None:
|
||||||
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
|
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
|
||||||
plg = entry_point.load()
|
if len(arguments.plugins) == 0 or entry_point.name in arguments.plugins:
|
||||||
if hasattr(plg, "models") and plg.models is not None:
|
plg = entry_point.load()
|
||||||
gen.run(plg.models)
|
if hasattr(plg, "models") and plg.models is not None:
|
||||||
|
gen.run(plg.models)
|
||||||
gen.write()
|
gen.write()
|
||||||
|
|
||||||
|
|
||||||
|
@ -183,7 +185,12 @@ if __name__ == "__main__":
|
||||||
parser_export.set_defaults(func=export)
|
parser_export.set_defaults(func=export)
|
||||||
parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts")
|
parser_export.add_argument("--file", help="Filename where to save", default="flaschengeist.d.ts")
|
||||||
parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG")
|
parser_export.add_argument("--namespace", help="Namespace of declarations", default="FG")
|
||||||
parser_export.add_argument("--plugins", help="Also export plugins", action="store_true")
|
parser_export.add_argument(
|
||||||
|
"--no-core",
|
||||||
|
help="Do not export core declarations (only useful in conjunction with --plugins)",
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*")
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
args.func(args)
|
args.func(args)
|
||||||
|
|
7
setup.py
7
setup.py
|
@ -41,7 +41,12 @@ setup(
|
||||||
"werkzeug",
|
"werkzeug",
|
||||||
mysql_driver,
|
mysql_driver,
|
||||||
],
|
],
|
||||||
extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]},
|
extras_require={
|
||||||
|
"ldap": ["flask_ldapconn", "ldap3"],
|
||||||
|
"argon": ["argon2-cffi"],
|
||||||
|
"pricelist": ["pillow"],
|
||||||
|
"test": ["pytest", "coverage"],
|
||||||
|
},
|
||||||
entry_points={
|
entry_points={
|
||||||
"flaschengeist.plugin": [
|
"flaschengeist.plugin": [
|
||||||
# Authentication providers
|
# Authentication providers
|
||||||
|
|
Loading…
Reference in New Issue