Compare commits

..

4 Commits

31 changed files with 389 additions and 960 deletions

2
.gitignore vendored
View File

@ -122,8 +122,6 @@ dmypy.json
.vscode/
*.log
data/
# config
flaschengeist/flaschengeist.toml

View File

@ -52,11 +52,8 @@ def __load_plugins(app):
app.register_blueprint(plugin.blueprint)
except:
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):
logger.debug(f"Found authentication plugin: {entry_point.name}")
if entry_point.name == config["FLASCHENGEIST"]["auth"]:
@ -68,7 +65,6 @@ def __load_plugins(app):
app.config["FG_PLUGINS"][entry_point.name] = plugin
if "FG_AUTH_BACKEND" not in app.config:
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():

View File

@ -41,21 +41,17 @@ def read_configuration(test_config):
update_dict(config, test_config)
def configure_logger():
def configure_app(app, test_config=None):
global config
# Read default config
logger_config = toml.load(_module_path / "logging.toml")
read_configuration(test_config)
# Always enable this builtin plugins!
update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}})
logger_config = toml.load(_module_path / "logging.toml")
if "LOGGING" in config:
# Override with user config
update_dict(logger_config, config.get("LOGGING"))
# Check for shortcuts
if "level" in config["LOGGING"]:
logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]}
logger_config["handlers"]["console"]["level"] = config["LOGGING"]["level"]
logger_config["handlers"]["file"]["level"] = config["LOGGING"]["level"]
if not config["LOGGING"].get("console", True):
logger_config["handlers"]["console"]["level"] = "CRITICAL"
if "file" in config["LOGGING"]:
logger_config["root"]["handlers"].append("file")
logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"]
@ -63,23 +59,6 @@ def configure_logger():
path.parent.mkdir(parents=True, exist_ok=True)
logging.config.dictConfig(logger_config)
def configure_app(app, test_config=None):
global config
read_configuration(test_config)
configure_logger()
# Always enable this builtin plugins!
update_dict(
config,
{
"auth": {"enabled": True},
"roles": {"enabled": True},
"users": {"enabled": True},
},
)
if "secret_key" not in config["FLASCHENGEIST"]:
logger.warning("No secret key was configured, please configure one for production systems!")
app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29"

View File

@ -1,65 +0,0 @@
from datetime import date
from flask import send_file
from pathlib import Path
from PIL import Image as PImage
from werkzeug.exceptions import NotFound, UnprocessableEntity
from werkzeug.datastructures import FileStorage
from werkzeug.utils import secure_filename
from flaschengeist.models.image import Image
from flaschengeist.database import db
from flaschengeist.config import config
def check_mimetype(mime: str):
return mime in config["FILES"].get("allowed_mimetypes", [])
def send_image(id: int = None, image: Image = None):
if image is None:
image = Image.query.get(id)
if not image:
raise NotFound
return send_file(image.path_, mimetype=image.mimetype_, download_name=image.filename_)
def send_thumbnail(id: int = None, image: Image = None):
if image is None:
image = Image.query.get(id)
if not image:
raise NotFound
if not image.thumbnail_:
with PImage.open(image.open()) as im:
im.thumbnail(tuple(config["FILES"].get("thumbnail_size")))
s = image.path_.split(".")
s.insert(len(s) - 1, "thumbnail")
im.save(".".join(s))
image.thumbnail_ = ".".join(s)
db.session.commit()
return send_file(image.thumbnail_, mimetype=image.mimetype_, download_name=image.filename_)
def upload_image(file: FileStorage):
if not check_mimetype(file.mimetype):
raise UnprocessableEntity
path = Path(config["FILES"].get("data_path")) / str(date.today().year)
path.mkdir(mode=int("0700", 8), parents=True, exist_ok=True)
if file.filename.count(".") < 1:
name = secure_filename(file.filename + "." + file.mimetype.split("/")[-1])
else:
name = secure_filename(file.filename)
img = Image(mimetype_=file.mimetype, filename_=name)
db.session.add(img)
db.session.flush()
try:
img.path_ = str((path / f"{img.id}.{img.filename_.split('.')[-1]}").resolve())
file.save(img.path_)
except:
db.session.delete(img)
raise
finally:
db.session.commit()
return img

View File

@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError
from werkzeug.exceptions import BadRequest, NotFound
from flaschengeist.models.user import Role, Permission
from flaschengeist.database import db, case_sensitive
from flaschengeist.database import db
from flaschengeist import logger
from flaschengeist.utils.hook import Hook
@ -36,8 +36,8 @@ def update_role(role, new_name):
except IntegrityError:
logger.debug("IntegrityError: Role might still be in use", exc_info=True)
raise BadRequest("Role still in use")
else:
if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar():
elif role.name != new_name:
if db.session.query(db.exists().where(Role.name == new_name)).scalar():
raise BadRequest("Name already used")
role.name = new_name
db.session.commit()

View File

@ -207,11 +207,6 @@ def save_avatar(user, avatar):
db.session.commit()
def delete_avatar(user):
current_app.config["FG_AUTH_BACKEND"].delete_avatar(user)
db.session.commit()
def persist(user=None):
if user:
db.session.add(user)

View File

@ -1,11 +1,3 @@
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
def case_sensitive(s):
if db.session.bind.dialect.name == "mysql":
from sqlalchemy import func
return func.binary(s)
return s

View File

@ -7,52 +7,39 @@ auth = "auth_plain"
# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn
#proxy = false
# Set root path, prefixes all routes
root = "/api"
#root = /api
# Set secret key
secret_key = "V3ryS3cr3t"
# Domain used by frontend
#domain = "flaschengeist.local"
[LOGGING]
# You can override all settings from the logging.toml here
# E.g. override the formatters etc
#
# Logging level, possible: DEBUG INFO WARNING ERROR
level = "DEBUG"
# Uncomment to enable logging to a file
# file = "/tmp/flaschengeist-debug.log"
# Uncomment to disable console logging
# console = False
#file = "/tmp/flaschengeist-debug.log"
# Logging level, possible: DEBUG INFO WARNING ERROR
level = "WARNING"
[DATABASE]
# engine = "mysql" (default)
[FILES]
# Path for file / image uploads
data_path = "./data"
# Thumbnail size
thumbnail_size = [192, 192]
# Accepted mimetypes
allowed_mimetypes = [
"image/avif",
"image/jpeg",
"image/png",
"image/webp"
]
# user = "user"
# host = "127.0.0.1"
# password = "password"
# database = "database"
[auth_plain]
enabled = true
[auth_ldap]
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
enabled = false
# host = "localhost"
# port = 389
# base_dn = "dc=example,dc=com"
# root_dn = "cn=Manager,dc=example,dc=com"
# root_secret = "SuperS3cret"
# Uncomment to use secured LDAP (ldaps)
# use_ssl = true
#[auth_ldap]
# enabled = true
# host =
# port =
# bind_dn =
# base_dn =
# secret =
# use_ssl =
# admin_dn =
# admin_dn =
# default_gid =
[MESSAGES]
welcome_subject = "Welcome to Flaschengeist {name}"

View File

@ -1,12 +1,9 @@
# This is the default flaschengeist logger configuration
# If you want to customize it, use the flaschengeist.toml
version = 1
disable_existing_loggers = false
[formatters]
[formatters.simple]
format = "%(asctime)s - %(levelname)s - %(message)s"
format = "%(asctime)s - %(name)s - %(message)s"
[formatters.extended]
format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s"

View File

@ -44,7 +44,6 @@ class ModelSerializeMixin:
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite")
@ -61,7 +60,6 @@ class UtcDateTime(TypeDecorator):
aware value, even with SQLite or MySQL.
"""
cache_ok = True
impl = DateTime(timezone=True)
@staticmethod

View File

@ -1,31 +0,0 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from sqlalchemy import event
from pathlib import Path
from . import ModelSerializeMixin, Serial
from ..database import db
class Image(db.Model, ModelSerializeMixin):
__tablename__ = "image"
id: int = db.Column("id", Serial, primary_key=True)
filename_: str = db.Column(db.String(127), nullable=False)
mimetype_: str = db.Column(db.String(30), nullable=False)
thumbnail_: str = db.Column(db.String(127))
path_: str = db.Column(db.String(127))
def open(self):
return open(self.path_, "rb")
@event.listens_for(Image, "before_delete")
def clear_file(mapper, connection, target: Image):
if target.path_:
p = Path(target.path_)
if p.exists():
p.unlink()
if target.thumbnail_:
p = Path(target.thumbnail_)
if p.exists():
p.unlink()

View File

@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin):
display_name: str = db.Column(db.String(30))
firstname: str = db.Column(db.String(50), nullable=False)
lastname: str = db.Column(db.String(50), nullable=False)
mail: str = db.Column(db.String(60))
mail: str = db.Column(db.String(60), nullable=False)
birthday: Optional[date] = db.Column(db.Date)
roles: list[str] = []
permissions: Optional[list[str]] = None
@ -67,9 +67,7 @@ class User(db.Model, ModelSerializeMixin):
sessions_ = db.relationship("Session", back_populates="user_")
_attributes = db.relationship(
"_UserAttribute",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete",
"_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete"
)
@property
@ -94,10 +92,6 @@ class User(db.Model, ModelSerializeMixin):
return self._attributes[name].value
return default
def delete_attribute(self, name):
if name in self._attributes:
self._attributes.pop(name)
def get_permissions(self):
return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions]

View File

@ -33,7 +33,6 @@ class Plugin:
blueprint = None # You have to override
permissions = [] # You have to override
id = "dev.flaschengeist.plugin" # You have to override
name = "plugin" # You have to override
models = None # You have to override
@ -95,7 +94,7 @@ class Plugin:
db.session.commit()
def notify(self, user, text: str, data=None):
n = Notification(text=text, data=data, plugin=self.id, user_=user)
n = Notification(text=text, data=data, plugin=self.name, user_=user)
db.session.add(n)
db.session.commit()
@ -191,14 +190,3 @@ class AuthPlugin(Plugin):
MethodNotAllowed: If not supported by Backend
"""
raise MethodNotAllowed
def delete_avatar(self, user):
"""Delete the avatar for given user (if supported by auth backend)
Args:
user: Uset to delete the avatar for
Raises:
MethodNotAllowed: If not supported by Backend
"""
raise MethodNotAllowed

View File

@ -1,61 +1,56 @@
"""LDAP Authentication Provider Plugin"""
import io
import os
import ssl
from typing import Optional
from flask_ldapconn import LDAPConn
from flask import current_app as app
from ldap3.utils.hashed import hashed
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from flaschengeist import logger
from flaschengeist.plugins import AuthPlugin, before_role_updated
from flaschengeist.plugins import AuthPlugin, after_role_updated
from flaschengeist.models.user import User, Role, _Avatar
import flaschengeist.controller.userController as userController
class AuthLDAP(AuthPlugin):
def __init__(self, config):
def __init__(self, cfg):
super().__init__()
config = {"port": 389, "use_ssl": False}
config.update(cfg)
app.config.update(
LDAP_SERVER=config.get("host", "localhost"),
LDAP_PORT=config.get("port", 389),
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_SERVER=config["host"],
LDAP_PORT=config["port"],
LDAP_BINDDN=config["bind_dn"],
LDAP_USE_TLS=False,
LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
LDAP_USE_SSL=config["use_ssl"],
LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2,
LDAP_REQUIRE_CERT=ssl.CERT_NONE,
FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
)
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
if "secret" in config:
app.config["LDAP_SECRET"] = config["secret"]
self.ldap = LDAPConn(app)
self.base_dn = config["base_dn"]
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", {})
self.dn_template = config.get("dn_template")
self.dn = config["base_dn"]
self.default_gid = config["default_gid"]
# TODO: might not be set if modify is called
self.root_dn = config.get("root_dn", None)
self.root_secret = config.get("root_secret", None)
if "admin_dn" in config:
self.admin_dn = config["admin_dn"]
self.admin_secret = config["admin_secret"]
else:
self.admin_dn = None
@before_role_updated
@after_role_updated
def _role_updated(role, new_name):
logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})")
self.__modify_role(role, new_name)
def login(self, user, password):
if not user:
return False
return self.ldap.authenticate(user.userid, password, "uid", self.base_dn)
return self.ldap.authenticate(user.userid, password, "uid", self.dn)
def find_user(self, userid, mail=None):
attr = self.__find(userid, mail)
@ -69,53 +64,43 @@ class AuthLDAP(AuthPlugin):
self.__update(user, attr)
def create_user(self, user, password):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
if self.admin_dn is None:
logger.error("admin_dn missing in ldap config!")
raise InternalServerError
try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
attributes = self.user_attributes.copy()
if "uidNumber" in attributes:
self.ldap.connection.search(
self.search_dn,
"(uidNumber=*)",
SUBTREE,
attributes=["uidNumber"],
)
resp = sorted(
self.ldap.response(),
key=lambda i: i["attributes"]["uidNumber"],
reverse=True,
)
attributes["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
dn = self.dn_template.format(
user=user,
base_dn=self.base_dn,
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
self.ldap.connection.search(
"ou=user,{}".format(self.dn),
"(uidNumber=*)",
SUBTREE,
attributes=["uidNumber"],
)
if "default_gid" in attributes:
default_gid = attributes.pop("default_gid")
attributes["gidNumber"] = default_gid
if "homeDirectory" in attributes:
attributes["homeDirectory"] = attributes.get("homeDirectory").format(
firstname=user.firstname,
lastname=user.lastname,
userid=user.userid,
mail=user.mail,
display_name=user.display_name,
)
attributes.update(
{
"sn": user.lastname,
"givenName": user.firstname,
"uid": user.userid,
"userPassword": self.__hash(password),
"mail": user.mail,
}
uid_number = (
sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][
"attributes"
]["uidNumber"]
+ 1
)
ldap_conn.add(dn, self.object_classes, attributes)
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,
"userPassword": hashed(HASHED_SALTED_MD5, password),
"uidNumber": uid_number,
}
ldap_conn.add(dn, object_class, attributes)
self._set_roles(user)
self.update_user(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest
@ -125,10 +110,10 @@ class AuthLDAP(AuthPlugin):
if password:
ldap_conn = self.ldap.connect(dn, password)
else:
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
if self.admin_dn is None:
logger.error("admin_dn missing in ldap config!")
raise InternalServerError
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
modifier = {}
for name, ldap_name in [
("firstname", "givenName"),
@ -139,7 +124,9 @@ class AuthLDAP(AuthPlugin):
if hasattr(user, name):
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
if new_password:
modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
# TODO: Use secure hash!
salted_password = hashed(HASHED_SALTED_MD5, new_password)
modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])]
ldap_conn.modify(dn, modifier)
self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError):
@ -147,7 +134,7 @@ class AuthLDAP(AuthPlugin):
def get_avatar(self, user):
self.ldap.connection.search(
self.search_dn,
"ou=user,{}".format(self.dn),
"(uid={})".format(user.userid),
SUBTREE,
attributes=["jpegPhoto"],
@ -157,14 +144,14 @@ class AuthLDAP(AuthPlugin):
if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0:
avatar = _Avatar()
avatar.mimetype = "image/jpeg"
avatar.binary = bytearray(r["jpegPhoto"][0])
avatar.binary.extend(r["jpegPhoto"][0])
return avatar
else:
raise NotFound
def set_avatar(self, user, avatar: _Avatar):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
if self.admin_dn is None:
logger.error("admin_dn missing in ldap config!")
raise InternalServerError
if avatar.mimetype != "image/jpeg":
@ -185,23 +172,16 @@ class AuthLDAP(AuthPlugin):
raise BadRequest("Unsupported image format")
dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
def delete_avatar(self, user):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
dn = user.get_attribute("DN")
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [])]})
def __find(self, userid, mail=None):
"""Find attributes of an user by uid or mail in LDAP"""
con = self.ldap.connection
if not con:
con = self.ldap.connect(self.root_dn, self.root_secret)
con = self.ldap.connect(self.admin_dn, self.admin_secret)
con.search(
self.search_dn,
f"ou=user,{self.dn}",
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
SUBTREE,
attributes=["uid", "givenName", "sn", "mail"],
@ -225,12 +205,12 @@ class AuthLDAP(AuthPlugin):
role: Role,
new_name: Optional[str],
):
if self.root_dn is None:
logger.error("root_dn missing in ldap config!")
if self.admin_dn is None:
logger.error("admin_dn missing in ldap config!")
raise InternalServerError
try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"])
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"])
if len(ldap_conn.response) > 0:
dn = ldap_conn.response[0]["dn"]
if new_name:
@ -238,33 +218,13 @@ class AuthLDAP(AuthPlugin):
else:
ldap_conn.delete(dn)
except LDAPPasswordIsMandatoryError:
except (LDAPPasswordIsMandatoryError, LDAPBindError):
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.encode() + salt).digest() + salt).decode()}"
def _get_groups(self, uid):
groups = []
self.ldap.connection.search(
self.group_dn,
"ou=group,{}".format(self.dn),
"(memberUID={})".format(uid),
SUBTREE,
attributes=["cn"],
@ -276,7 +236,7 @@ class AuthLDAP(AuthPlugin):
def _get_all_roles(self):
self.ldap.connection.search(
self.group_dn,
f"ou=group,{self.dn}",
"(cn=*)",
SUBTREE,
attributes=["cn", "gidNumber", "memberUid"],
@ -285,7 +245,8 @@ class AuthLDAP(AuthPlugin):
def _set_roles(self, user: User):
try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_roles = self._get_all_roles()
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
@ -294,7 +255,7 @@ class AuthLDAP(AuthPlugin):
for user_role in user.roles:
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
ldap_conn.add(
f"cn={user_role},{self.group_dn}",
f"cn={user_role},ou=group,{self.dn}",
["posixGroup"],
attributes={"gidNumber": gid_number},
)

View File

@ -19,13 +19,7 @@ class AuthPlain(AuthPlugin):
if User.query.first() is None:
logger.info("Installing admin user")
role = Role(name="Superuser", permissions=Permission.query.all())
admin = User(
userid="admin",
firstname="Admin",
lastname="Admin",
mail="",
roles_=[role],
)
admin = User(userid="admin", firstname="Admin", lastname="Admin", mail="", roles_=[role])
self.modify_user(admin, None, "admin")
db.session.add(admin)
db.session.commit()
@ -64,9 +58,6 @@ class AuthPlain(AuthPlugin):
def set_avatar(self, user, avatar):
user.set_attribute("avatar", avatar)
def delete_avatar(self, user):
user.delete_attribute("avatar")
@staticmethod
def _hash_password(password):
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii")

View File

@ -114,14 +114,7 @@ def get_transaction(transaction_id) -> Transaction:
def get_transactions(
user,
start=None,
end=None,
limit=None,
offset=None,
show_reversal=False,
show_cancelled=True,
descending=False,
user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False
):
count = None
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))

View File

@ -11,7 +11,6 @@ from . import permissions, models
class EventPlugin(Plugin):
name = "events"
id = "dev.flaschengeist.events"
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name])
permissions = permissions.permissions
blueprint = Blueprint(name, __name__)

View File

@ -1,7 +1,7 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from werkzeug.exceptions import BadRequest, Conflict, NotFound
from werkzeug.exceptions import BadRequest, NotFound
from sqlalchemy.exc import IntegrityError
from flaschengeist import logger
@ -41,7 +41,7 @@ def create_event_type(name):
db.session.commit()
return event
except IntegrityError:
raise Conflict("Name already exists")
raise BadRequest("Name already exists")
def rename_event_type(identifier, new_name):
@ -50,7 +50,7 @@ def rename_event_type(identifier, new_name):
try:
db.session.commit()
except IntegrityError:
raise Conflict("Name already exists")
raise BadRequest("Name already exists")
def delete_event_type(name):
@ -116,7 +116,7 @@ def get_event(event_id, with_backup=False) -> Event:
if event is None:
raise NotFound
if not with_backup:
clear_backup(event)
return clear_backup(event)
return event
@ -124,14 +124,7 @@ def get_templates():
return Event.query.filter(Event.is_template == True).all()
def get_events(
start: Optional[datetime] = None,
end: Optional[datetime] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
descending: Optional[bool] = False,
with_backup=False,
):
def get_events(start: Optional[datetime] = None, end=None, with_backup=False):
"""Query events which start from begin until end
Args:
start (datetime): Earliest start
@ -145,14 +138,6 @@ def get_events(
query = query.filter(start <= Event.start)
if end is not None:
query = query.filter(Event.start < end)
if descending:
query = query.order_by(Event.start.desc())
else:
query = query.order_by(Event.start)
if limit is not None:
query = query.limit(limit)
if offset is not None and offset > 0:
query = query.offset(offset)
events = query.all()
if not with_backup:
for event in events:
@ -168,9 +153,7 @@ def delete_event(event_id):
Raises:
NotFound if not found
"""
event = get_event(event_id, True)
for job in event.jobs:
delete_job(job)
event = get_event(event_id)
db.session.delete(event)
db.session.commit()
@ -195,24 +178,15 @@ def create_event(event_type, start, end=None, jobs=[], is_template=None, name=No
raise BadRequest
def get_job(job_id, event_id=None) -> Job:
query = Job.query.filter(Job.id == job_id)
if event_id is not None:
query = query.filter(Job.event_id_ == event_id)
job = query.one_or_none()
if job is None:
def get_job(job_slot_id, event_id):
js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none()
if js is None:
raise NotFound
return job
return js
def add_job(event, job_type, required_services, start, end=None, comment=None):
job = Job(
required_services=required_services,
type=job_type,
start=start,
end=end,
comment=comment,
)
job = Job(required_services=required_services, type=job_type, start=start, end=end, comment=comment)
event.jobs.append(job)
update()
return job
@ -222,48 +196,30 @@ def update():
try:
db.session.commit()
except IntegrityError:
logger.debug(
"Error, looks like a Job with that type already exists on an event",
exc_info=True,
)
logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True)
raise BadRequest()
def delete_job(job: Job):
for service in job.services:
unassign_job(service=service, notify=True)
db.session.delete(job)
db.session.commit()
def assign_job(job: Job, user, value, is_backup=False):
assert value > 0
def assign_to_job(job: Job, user, value):
service = Service.query.get((job.id, user.id_))
if service:
service.value = value
if value < 0:
if not service:
raise BadRequest
db.session.delete(service)
else:
service = Service(user_=user, value=value, is_backup=is_backup, job_=job)
db.session.add(service)
if service:
service.value = value
else:
service = Service(user_=user, value=value, job_=job)
db.session.add(service)
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
def assign_backups():
logger.debug("Notifications")
@ -274,9 +230,7 @@ def assign_backups():
for service in services:
if service.job_.start <= now or service.job_.is_full():
EventPlugin.plugin.notify(
service.user_,
"Your backup assignment was cancelled.",
{"event_id": service.job_.event_id_},
service.user_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_}
)
logger.debug(f"Service is outdated or full, removing. {service.serialize()}")
db.session.delete(service)
@ -284,8 +238,6 @@ def assign_backups():
service.is_backup = False
logger.debug(f"Service not full, assigning backup. {service.serialize()}")
EventPlugin.plugin.notify(
service.user_,
"Your backup assignment was accepted.",
{"event_id": service.job_.event_id_},
service.user_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_}
)
db.session.commit()

View File

@ -39,13 +39,7 @@ class Service(db.Model, ModelSerializeMixin):
is_backup: bool = db.Column(db.Boolean, default=False)
value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False)
_job_id = db.Column(
"job_id",
Serial,
db.ForeignKey(f"{_table_prefix_}job.id"),
nullable=False,
primary_key=True,
)
_job_id = db.Column("job_id", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True)
user_: User = db.relationship("User")
@ -65,7 +59,6 @@ class Job(db.Model, ModelSerializeMixin):
end: Optional[datetime] = db.Column(UtcDateTime)
type: Union[JobType, int] = db.relationship("JobType")
comment: Optional[str] = db.Column(db.String(256))
locked: bool = db.Column(db.Boolean())
services: list[Service] = db.relationship("Service", back_populates="job_")
required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False)
@ -90,17 +83,11 @@ class Event(db.Model, ModelSerializeMixin):
type: Union[EventType, int] = db.relationship("EventType")
is_template: bool = db.Column(db.Boolean, default=False)
jobs: list[Job] = db.relationship(
"Job",
back_populates="event_",
cascade="all,delete,delete-orphan",
order_by="[Job.start, Job.end]",
"Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]"
)
# Protected for internal use
_type_id = db.Column(
"type_id",
Serial,
db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"),
nullable=False,
"type_id", Serial, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False
)

View File

@ -22,7 +22,4 @@ ASSIGN_OTHER = "events_assign_other"
SEE_BACKUP = "events_see_backup"
"""Can see users assigned as backup"""
LOCK_JOBS = "events_lock_jobs"
"""Can lock jobs, no further services can be assigned or unassigned"""
permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -1,12 +1,9 @@
from datetime import datetime, timedelta, timezone
from http.client import NO_CONTENT
from re import template
from flask import request, jsonify
from sqlalchemy import exc
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
from flaschengeist.models.session import Session
from flaschengeist.plugins.events.models import Job
from flaschengeist.utils.decorators import login_required
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.controller import userController
@ -15,21 +12,6 @@ from . import event_controller, permissions, EventPlugin
from ...utils.HTTP import no_content
def dict_get(self, key, default=None, type=None):
"""Same as .get from MultiDict"""
try:
rv = self[key]
except KeyError:
return default
if type is not None:
try:
rv = type(rv)
except ValueError:
rv = default
return rv
@EventPlugin.blueprint.route("/events/templates", methods=["GET"])
@login_required()
def get_templates(current_session):
@ -187,8 +169,7 @@ def get_event(event_id, current_session):
JSON encoded event object
"""
event = event_controller.get_event(
event_id,
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
event_id, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP)
)
return jsonify(event)
@ -196,21 +177,17 @@ def get_event(event_id, current_session):
@EventPlugin.blueprint.route("/events", methods=["GET"])
@login_required()
def get_filtered_events(current_session):
begin = request.args.get("from", type=from_iso_format)
end = request.args.get("to", type=from_iso_format)
limit = request.args.get("limit", type=int)
offset = request.args.get("offset", type=int)
descending = "descending" in request.args
begin = request.args.get("from")
if begin is not None:
begin = from_iso_format(begin)
end = request.args.get("to")
if end is not None:
end = from_iso_format(end)
if begin is None and end is None:
begin = datetime.now()
return jsonify(
event_controller.get_events(
start=begin,
end=end,
limit=limit,
offset=offset,
descending=descending,
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP)
)
)
@ -245,9 +222,7 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m
end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc)
events = event_controller.get_events(
begin,
end,
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP)
)
return jsonify(events)
except ValueError:
@ -257,7 +232,9 @@ def get_events(current_session, year=datetime.now().year, month=datetime.now().m
def _add_job(event, data):
try:
start = from_iso_format(data["start"])
end = dict_get(data, "end", None, type=from_iso_format)
end = None
if "end" in data:
end = from_iso_format(data["end"])
required_services = data["required_services"]
job_type = data["type"]
if isinstance(job_type, dict):
@ -266,14 +243,7 @@ def _add_job(event, data):
raise BadRequest("Missing or invalid POST parameter")
job_type = event_controller.get_job_type(job_type)
event_controller.add_job(
event,
job_type,
required_services,
start,
end,
comment=dict_get(data, "comment", None, str),
)
event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None))
@EventPlugin.blueprint.route("/events", methods=["POST"])
@ -292,9 +262,11 @@ def create_event(current_session):
JSON encoded Event object or HTTP-error
"""
data = request.get_json()
end = data.get("end", None)
try:
start = from_iso_format(data["start"])
end = dict_get(data, "end", None, type=from_iso_format)
if end is not None:
end = from_iso_format(end)
data_type = data["type"]
if isinstance(data_type, dict):
data_type = data["type"]["id"]
@ -307,10 +279,10 @@ def create_event(current_session):
event = event_controller.create_event(
start=start,
end=end,
name=dict_get(data, "name", None),
is_template=dict_get(data, "is_template", None),
name=data.get("name", None),
is_template=data.get("is_template", None),
event_type=event_type,
description=dict_get(data, "description", None),
description=data.get("description", None),
)
if "jobs" in data:
for job in data["jobs"]:
@ -337,14 +309,15 @@ def modify_event(event_id, current_session):
"""
event = event_controller.get_event(event_id)
data = request.get_json()
event.start = dict_get(data, "start", event.start, type=from_iso_format)
event.end = dict_get(data, "end", event.end, type=from_iso_format)
event.name = dict_get(data, "name", event.name, type=str)
event.description = dict_get(data, "description", event.description, type=str)
if "start" in data:
event.start = from_iso_format(data["start"])
if "end" in data:
event.end = from_iso_format(data["end"])
if "description" in data:
event.description = data["description"]
if "type" in data:
event_type = event_controller.get_event_type(data["type"])
event.type = event_type
event_controller.update()
return jsonify(event)
@ -403,19 +376,19 @@ def delete_job(event_id, job_id, current_session):
Returns:
HTTP-no-content or HTTP error
"""
job = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job)
job_slot = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job_slot)
return no_content()
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"])
@login_required()
def update_job(event_id, job_id, current_session: Session):
"""Edit Job
"""Edit Job or assign user to the Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``PUT``
POST-data: See TS interface for Job
POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}``
Args:
event_id: Identifier of the event
@ -425,89 +398,34 @@ def update_job(event_id, job_id, current_session: Session):
Returns:
JSON encoded Job object or HTTP-error
"""
if not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden
job = event_controller.get_job(job_id, event_id)
data = request.get_json()
if not data:
raise BadRequest
job = event_controller.get_job(job_id, event_id)
try:
if "type" in data:
job.type = event_controller.get_job_type(data["type"])
job.start = from_iso_format(data.get("start", job.start))
job.end = from_iso_format(data.get("end", job.end))
job.comment = str(data.get("comment", job.comment))
job.locked = bool(data.get("locked", job.locked))
job.required_services = float(data.get("required_services", job.required_services))
event_controller.update()
except NotFound:
raise BadRequest("Invalid JobType")
except ValueError:
raise BadRequest("Invalid POST data")
if ("user" not in data or len(data) > 1) and not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden
if "user" in data:
try:
user = userController.get_user(data["user"]["userid"])
value = data["user"]["value"]
if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or (
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
):
raise Forbidden
event_controller.assign_to_job(job, user, value)
except (KeyError, ValueError):
raise BadRequest
if "required_services" in data:
job.required_services = data["required_services"]
if "type" in data:
job.type = event_controller.get_job_type(data["type"])
event_controller.update()
return jsonify(job)
@EventPlugin.blueprint.route("/events/jobs/<int:job_id>/assign", methods=["POST"])
@login_required()
def assign_job(job_id, current_session: Session):
"""Assign / unassign user to the Job
Route: ``/events/jobs/<job_id>/assign`` | Method: ``POST``
POST-data: a Service object, see TS interface for Service
Args:
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-No-Content or HTTP-error
"""
data = request.get_json()
job = event_controller.get_job(job_id)
try:
user = userController.get_user(data["userid"])
value = data["value"]
if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or (
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
):
raise Forbidden
if value > 0:
event_controller.assign_job(job, user, value, data.get("is_backup", False))
else:
event_controller.unassign_job(job, user, notify=user != current_session.user_)
except (TypeError, KeyError, ValueError):
raise BadRequest
return no_content()
@EventPlugin.blueprint.route("/events/jobs/<int:job_id>/lock", methods=["POST"])
@login_required(permissions.LOCK_JOBS)
def lock_job(job_id, current_session: Session):
"""Lock / unlock the Job
Route: ``/events/jobs/<job_id>/lock`` | Method: ``POST``
POST-data: ``{locked: boolean}``
Args:
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-No-Content or HTTP-error
"""
data = request.get_json()
job = event_controller.get_job(job_id)
try:
locked = bool(userController.get_user(data["locked"]))
job.locked = locked
event_controller.update()
except (TypeError, KeyError, ValueError):
raise BadRequest
return no_content()
# TODO: JobTransfer

View File

@ -2,13 +2,12 @@
from flask import Blueprint, jsonify, request, current_app
from werkzeug.local import LocalProxy
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized
from flaschengeist import logger
from flaschengeist.controller import userController
from flaschengeist.controller.imageController import send_image, send_thumbnail
from flaschengeist.plugins import Plugin
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.utils.decorators import login_required, extract_session
from flaschengeist.utils.HTTP import no_content
from . import models
@ -215,87 +214,10 @@ def get_drinks(identifier=None):
if identifier:
result = pricelist_controller.get_drink(identifier, public=public)
return jsonify(result)
else:
limit = request.args.get("limit")
offset = request.args.get("offset")
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,
)
mop = drinks.copy()
logger.debug(f"GET drink {drinks}, {count}")
# return jsonify({"drinks": drinks, "count": count})
return jsonify({"drinks": drinks, "count": count})
@PriceListPlugin.blueprint.route("/list", methods=["GET"])
def get_pricelist():
"""Get Priclist
Route: ``/pricelist/list`` | Method: ``GET``
Returns:
JSON encoded list of DrinkPrices or HTTP-KeyError
"""
public = True
try:
extract_session()
public = False
except Unauthorized:
public = True
limit = request.args.get("limit")
offset = request.args.get("offset")
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)
descending = request.args.get("descending", type=bool)
sortBy = request.args.get("sortBy")
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)
if descending is not None:
descending = bool(descending)
except ValueError:
raise BadRequest
pricelist, count = pricelist_controller.get_pricelist(
public=public,
limit=limit,
offset=offset,
search_name=search_name,
search_key=search_key,
descending=descending,
sortBy=sortBy,
)
logger.debug(f"GET pricelist {pricelist}, {count}")
return jsonify({"pricelist": pricelist, "count": count})
result = pricelist_controller.get_drinks(public=public)
logger.debug(f"GET drink {result}")
return jsonify(result)
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
@ -710,7 +632,7 @@ def get_priclist_setting(userid, current_session):
return no_content()
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "DELETE"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "GET", "DELETE"])
@login_required(permission=permissions.EDIT)
def set_picture(identifier, current_session):
"""Get, Create, Delete Drink Picture
@ -732,25 +654,25 @@ def set_picture(identifier, current_session):
file = request.files.get("file")
if file:
return jsonify(pricelist_controller.save_drink_picture(identifier, file))
picture = models._Picture()
picture.mimetype = file.content_type
picture.binary = bytearray(file.stream.read())
return jsonify(pricelist_controller.save_drink_picture(identifier, picture))
else:
raise BadRequest
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["GET"])
#@headers({"Cache-Control": "private, must-revalidate"})
@PriceListPlugin.blueprint.route("/picture/<identifier>", methods=["GET"])
def _get_picture(identifier):
"""Get Picture
Args:
identifier: Identifier of Drink
identifier: Identifier of Picture
Returns:
Picture or HTTP-error
"""
drink = pricelist_controller.get_drink(identifier)
if drink.has_image:
if request.args.get("thumbnail"):
return send_thumbnail(image=drink.image_)
return send_image(image=drink.image_)
raise NotFound
if request.method == "GET":
size = request.args.get("size")
response = pricelist_controller.get_drink_picture(identifier, size)
return response.make_conditional(request)

View File

@ -1,21 +1,20 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from flaschengeist.database import db
from flaschengeist.models import ModelSerializeMixin, Serial
from flaschengeist.models.image import Image
from flaschengeist.models import ModelSerializeMixin
from typing import Optional
drink_tag_association = db.Table(
"drink_x_tag",
db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
db.Column("tag_id", Serial, db.ForeignKey("drink_tag.id")),
db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")),
db.Column("tag_id", db.Integer, db.ForeignKey("drink_tag.id")),
)
drink_type_association = db.Table(
"drink_x_type",
db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
db.Column("type_id", Serial, db.ForeignKey("drink_type.id")),
db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")),
db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")),
)
@ -25,7 +24,7 @@ class Tag(db.Model, ModelSerializeMixin):
"""
__tablename__ = "drink_tag"
id: int = db.Column("id", Serial, primary_key=True)
id: int = db.Column("id", db.Integer, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
color: str = db.Column(db.String(7), nullable=False)
@ -36,7 +35,7 @@ class DrinkType(db.Model, ModelSerializeMixin):
"""
__tablename__ = "drink_type"
id: int = db.Column("id", Serial, primary_key=True)
id: int = db.Column("id", db.Integer, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
@ -46,11 +45,10 @@ class DrinkPrice(db.Model, ModelSerializeMixin):
"""
__tablename__ = "drink_price"
id: int = db.Column("id", Serial, primary_key=True)
id: int = db.Column("id", db.Integer, primary_key=True)
price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
volume_id_ = db.Column("volume_id", Serial, db.ForeignKey("drink_price_volume.id"))
volume: "DrinkPriceVolume" = None
_volume: "DrinkPriceVolume" = db.relationship("DrinkPriceVolume", back_populates="_prices", join_depth=1)
volume_id_ = db.Column("volume_id", db.Integer, db.ForeignKey("drink_price_volume.id"))
volume = db.relationship("DrinkPriceVolume", back_populates="prices")
public: bool = db.Column(db.Boolean, default=True)
description: Optional[str] = db.Column(db.String(30))
@ -63,8 +61,8 @@ class ExtraIngredient(db.Model, ModelSerializeMixin):
ExtraIngredient
"""
__tablename__ = "drink_extra_ingredient"
id: int = db.Column("id", Serial, primary_key=True)
__tablename__ = "extra_ingredient"
id: int = db.Column("id", db.Integer, primary_key=True)
name: str = db.Column(db.String(30), unique=True, nullable=False)
price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
@ -75,20 +73,19 @@ class DrinkIngredient(db.Model, ModelSerializeMixin):
"""
__tablename__ = "drink_ingredient"
id: int = db.Column("id", Serial, 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)
ingredient_id: int = db.Column(Serial, db.ForeignKey("drink.id"))
cost_per_volume: float
name: str
_drink_ingredient: Drink = db.relationship("Drink")
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id"))
# drink_ingredient: Drink = db.relationship("Drink")
# price: float = 0
@property
def cost_per_volume(self):
return self._drink_ingredient.cost_per_volume if self._drink_ingredient else None
@property
def name(self):
return self._drink_ingredient.name if self._drink_ingredient else None
# @property
# def price(self):
# try:
# return self.drink_ingredient.cost_price_pro_volume * self.volume
# except AttributeError:
# pass
class Ingredient(db.Model, ModelSerializeMixin):
@ -96,14 +93,14 @@ class Ingredient(db.Model, ModelSerializeMixin):
Ingredient Associationtable
"""
__tablename__ = "drink_ingredient_association"
id: int = db.Column("id", Serial, primary_key=True)
volume_id = db.Column(Serial, db.ForeignKey("drink_price_volume.id"))
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient, cascade="all,delete")
__tablename__ = "ingredient_association"
id: int = db.Column("id", db.Integer, primary_key=True)
volume_id = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id"))
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient)
extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient)
_drink_ingredient_id = db.Column(Serial, db.ForeignKey("drink_ingredient.id"))
_extra_ingredient_id = db.Column(Serial, db.ForeignKey("drink_extra_ingredient.id"))
_drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id"))
_extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id"))
class MinPrices(ModelSerializeMixin):
@ -121,25 +118,17 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin):
"""
__tablename__ = "drink_price_volume"
id: int = db.Column("id", Serial, primary_key=True)
drink_id = db.Column(Serial, db.ForeignKey("drink.id"))
drink: "Drink" = None
_drink: "Drink" = db.relationship("Drink", back_populates="_volumes")
id: int = db.Column("id", db.Integer, primary_key=True)
drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"))
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
min_prices: list[MinPrices] = []
# ingredients: list[Ingredient] = []
prices: list[DrinkPrice] = []
_prices: list[DrinkPrice] = db.relationship(
DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan"
)
ingredients: list[Ingredient] = db.relationship(
"Ingredient",
foreign_keys=Ingredient.volume_id,
cascade="all,delete,delete-orphan",
)
prices: list[DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan")
ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id)
def __repr__(self):
return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})"
return f"DrinkPriceVolume({self.id},{self.drink_id},{self.prices})"
class Drink(db.Model, ModelSerializeMixin):
@ -148,32 +137,29 @@ class Drink(db.Model, ModelSerializeMixin):
"""
__tablename__ = "drink"
id: int = db.Column("id", Serial, primary_key=True)
id: int = db.Column("id", db.Integer, primary_key=True)
article_id: Optional[str] = db.Column(db.String(64))
package_size: Optional[int] = db.Column(db.Integer)
name: str = db.Column(db.String(60), nullable=False)
volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
cost_per_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
has_image: bool = False
uuid: str = db.Column(db.String(36))
receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4))
_type_id = db.Column("type_id", Serial, db.ForeignKey("drink_type.id"))
_image_id = db.Column("image_id", Serial, db.ForeignKey("image.id"))
image_: Image = db.relationship("Image", cascade="all, delete", foreign_keys=[_image_id])
_type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id"))
tags: Optional[list[Tag]] = db.relationship("Tag", secondary=drink_tag_association, cascade="save-update, merge")
type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id])
volumes: list[DrinkPriceVolume] = []
_volumes: list[DrinkPriceVolume] = db.relationship(
DrinkPriceVolume, back_populates="_drink", cascade="all,delete,delete-orphan"
)
volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume)
def __repr__(self):
return f"Drink({self.id},{self.name},{self.volumes})"
@property
def has_image(self):
return self.image_ is not None
class _Picture:
"""Wrapper class for pictures binaries"""
mimetype = ""
binary = bytearray()

View File

@ -1,24 +1,16 @@
from werkzeug.exceptions import BadRequest, NotFound
from sqlalchemy.exc import IntegrityError
from uuid import uuid4
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.database import db
from flaschengeist.utils.picture import save_picture, get_picture, delete_picture
from flaschengeist.utils.decorators import extract_session
from .models import (
Drink,
DrinkPrice,
Ingredient,
Tag,
DrinkType,
DrinkPriceVolume,
DrinkIngredient,
ExtraIngredient,
)
from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient
from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK
import flaschengeist.controller.imageController as image_controller
def update():
db.session.commit()
@ -139,143 +131,13 @@ def _create_public_drink(drink):
return None
def get_drinks(
name=None,
public=False,
limit=None,
offset=None,
search_name=None,
search_key=None,
ingredient=False,
receipt=None,
):
count = None
def get_drinks(name=None, public=False):
if name:
query = Drink.query.filter(Drink.name.contains(name))
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))
drinks = Drink.query.filter(Drink.name.contains(name)).all()
drinks = Drink.query.all()
if public:
query = query.filter(Drink._volumes.any(DrinkPriceVolume._prices.any(DrinkPrice.public)))
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)))
)
query = query.order_by(Drink.name.asc())
if limit is not None:
count = query.count()
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
drinks = query.all()
for drink in drinks:
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drinks, count
def get_pricelist(
public=False,
limit=None,
offset=None,
search_name=None,
search_key=None,
sortBy=None,
descending=False,
):
count = None
query = DrinkPrice.query
if public:
query = query.filter(DrinkPrice.public)
if search_name:
if search_key == "name":
query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name))))
if search_key == "type":
query = query.filter(
DrinkPrice._volume.has(
DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name)))
)
)
if search_key == "tags":
query = query.filter(
DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.conaitns(search_name))))
)
if search_key == "volume":
query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name)))
if search_key == "price":
query = query.filter(DrinkPrice.price == float(search_name))
if search_key == "description":
query = query.filter(DrinkPrice.description.contains(search_name))
else:
try:
search_name = float(search_name)
query = query.filter(
(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name)))
| (DrinkPrice.price == float(search_name))
)
except:
query = query.filter(
(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name))))
| (
DrinkPrice._volume.has(
DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name)))
)
)
| (
DrinkPrice._volume.has(
DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.contains(search_name)))
)
)
| (DrinkPrice.description.contains(search_name))
)
if sortBy == "type":
query = (
query.join(DrinkPrice._volume)
.join(DrinkPriceVolume._drink)
.join(Drink.type)
.order_by(DrinkType.name.desc() if descending else DrinkType.name.asc())
)
elif sortBy == "volume":
query = query.join(DrinkPrice._volume).order_by(
DrinkPriceVolume.volume.desc() if descending else DrinkPriceVolume.volume.asc()
)
elif sortBy == "price":
query = query.order_by(DrinkPrice.price.desc() if descending else DrinkPrice.price.asc())
else:
query = (
query.join(DrinkPrice._volume)
.join(DrinkPriceVolume._drink)
.order_by(Drink.name.desc() if descending else Drink.name.asc())
)
if limit is not None:
count = query.count()
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
prices = query.all()
for price in prices:
price._volume.drink = price._volume._drink
price.volume = price._volume
return prices, count
return [_create_public_drink(drink) for drink in drinks if _create_public_drink(drink)]
return drinks
def get_drink(identifier, public=False):
@ -290,9 +152,6 @@ def get_drink(identifier, public=False):
raise NotFound
if public:
return _create_public_drink(drink)
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drink
@ -323,21 +182,17 @@ def update_drink(identifier, data):
else:
drink = get_drink(identifier)
for key, value in data.items():
if hasattr(drink, key) and key != "has_image":
if hasattr(drink, key):
setattr(drink, key, value if value != "" else None)
if drink_type:
drink.type = drink_type
if volumes is not None and session.user_.has_permission(EDIT_VOLUME):
drink._volumes = []
drink._volumes = set_volumes(volumes)
drink.volumes = []
drink.volumes = set_volumes(volumes)
if len(tags) > 0:
drink.tags = tags
db.session.commit()
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drink
except (NotFound, KeyError):
raise BadRequest
@ -378,9 +233,16 @@ def set_volume(data):
prices = values.pop("prices")
if "ingredients" in values:
ingredients = values.pop("ingredients")
values.pop("id", None)
volume = DrinkPriceVolume(**values)
db.session.add(volume)
vol_id = values.pop("id", None)
if vol_id < 0:
volume = DrinkPriceVolume(**values)
db.session.add(volume)
else:
volume = get_volume(vol_id)
if not volume:
raise NotFound
for key, value in values.items():
setattr(volume, key, value if value != "" else None)
if prices and session.user_.has_permission(EDIT_PRICE):
set_prices(prices, volume)
@ -395,7 +257,7 @@ def set_prices(prices, volume):
for _price in prices:
price = set_price(_price)
_prices.append(price)
volume._prices = _prices
volume.prices = _prices
def set_ingredients(ingredients, volume):
@ -430,9 +292,16 @@ def set_price(data):
allowed_keys.append("description")
logger.debug(f"allowed_key {allowed_keys}")
values = {key: value for key, value in data.items() if key in allowed_keys}
values.pop("id", -1)
price = DrinkPrice(**values)
db.session.add(price)
price_id = values.pop("id", -1)
if price_id < 0:
price = DrinkPrice(**values)
db.session.add(price)
else:
price = get_price(price_id)
if not price:
raise NotFound
for key, value in values.items():
setattr(price, key, value)
return price
@ -446,13 +315,16 @@ def delete_price(identifier):
def set_drink_ingredient(data):
allowed_keys = DrinkIngredient().serialize().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")
values.pop("id", -1)
drink_ingredient = DrinkIngredient(**values)
db.session.add(drink_ingredient)
ingredient_id = values.pop("id", -1)
if ingredient_id < 0:
drink_ingredient = DrinkIngredient(**values)
db.session.add(drink_ingredient)
else:
drink_ingredient = DrinkIngredient.query.get(ingredient_id)
if not drink_ingredient:
raise NotFound
for key, value in values.items():
setattr(drink_ingredient, key, value if value != "" else None)
return drink_ingredient
@ -467,9 +339,14 @@ def set_ingredient(data):
drink_ingredient_value = data.pop("drink_ingredient")
if "extra_ingredient" in data:
extra_ingredient_value = data.pop("extra_ingredient")
data.pop("id", -1)
ingredient = Ingredient(**data)
db.session.add(ingredient)
ingredient_id = data.pop("id", -1)
if ingredient_id < 0:
ingredient = Ingredient(**data)
db.session.add(ingredient)
else:
ingredient = get_ingredient(ingredient_id)
if not ingredient:
raise NotFound
if drink_ingredient_value:
ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value)
if extra_ingredient_value:
@ -525,16 +402,34 @@ def delete_extra_ingredient(identifier):
def save_drink_picture(identifier, file):
drink = delete_drink_picture(identifier)
drink.image_ = image_controller.upload_image(file)
drink = get_drink(identifier)
old_uuid = None
if drink.uuid:
old_uuid = drink.uuid
drink.uuid = str(uuid4())
db.session.commit()
path = config["pricelist"]["path"]
save_picture(file, f"{path}/{drink.uuid}")
if old_uuid:
delete_picture(f"{path}/{old_uuid}")
return drink
def get_drink_picture(identifier, size=None):
path = config["pricelist"]["path"]
drink = None
if isinstance(identifier, int):
drink = get_drink(identifier)
if isinstance(identifier, str):
drink = Drink.query.filter(Drink.uuid == identifier).one_or_none()
if drink:
return get_picture(f"{path}/{drink.uuid}", size)
raise FileNotFoundError
def delete_drink_picture(identifier):
drink = get_drink(identifier)
if drink.image_:
db.session.delete(drink.image_)
drink.image_ = None
db.session.commit()
return drink
if drink.uuid:
delete_picture(f"{config['pricelist']['path']}/{drink.uuid}")
drink.uuid = None
db.session.commit()

View File

@ -75,7 +75,7 @@ def list_users(current_session):
@UsersPlugin.blueprint.route("/users/<userid>", methods=["GET"])
@login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=300"})
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def get_user(userid, current_session):
"""Retrieve user by userid
@ -144,16 +144,6 @@ def set_avatar(userid, current_session):
raise BadRequest
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["DELETE"])
@login_required()
def delete_avatar(userid, current_session):
user = userController.get_user(userid)
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden
userController.delete_avatar(user)
return "", NO_CONTENT
@UsersPlugin.blueprint.route("/users/<userid>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_user(userid, current_session):

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -0,0 +1,55 @@
import os, sys, shutil, io
from PIL import Image
from flask import Response
from werkzeug.exceptions import BadRequest
from ..utils.HTTP import no_content
thumbnail_sizes = ((32, 32), (64, 64), (128, 128), (256, 256), (512, 512))
def save_picture(picture, path):
if not picture.mimetype.startswith("image/"):
raise BadRequest
os.makedirs(path, exist_ok=True)
file_type = picture.mimetype.replace("image/", "")
filename = f"{path}/drink"
with open(f"{filename}.{file_type}", "wb") as file:
file.write(picture.binary)
image = Image.open(f"{filename}.{file_type}")
if file_type != "png":
image.save(f"{filename}.png", "PNG")
os.remove(f"{filename}.{file_type}")
for thumbnail_size in thumbnail_sizes:
work_image = image.copy()
work_image.thumbnail(thumbnail_size)
work_image.save(f"{filename}-{thumbnail_size[0]}.png", "PNG")
def get_picture(path, size=None):
try:
if size:
if os.path.isfile(f"{path}/drink-{size}.png"):
with open(f"{path}/drink-{size}.png", "rb") as file:
image = file.read()
else:
_image = Image.open(f"{path}/drink.png")
_image.thumbnail((int(size), int(size)))
with io.BytesIO() as file:
_image.save(file, format="PNG")
image = file.getvalue()
else:
with open(f"{path}/drink.png", "rb") as file:
image = file.read()
response = Response(image, mimetype="image/png")
response.add_etag()
return response
except:
raise FileNotFoundError
def delete_picture(path):
try:
shutil.rmtree(path)
except FileNotFoundError:
pass

View File

@ -46,7 +46,7 @@ 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 "GRANT ALL PRIVILEGES ON 'flaschengeist'.* TO 'flaschengeist'@'localhost';"
echo "FLUSH PRIVILEGES;"
) | sudo mysql

View File

@ -167,28 +167,6 @@ def export(arguments):
gen.write()
def ldap_sync(arguments):
from flaschengeist.app import create_app
from flaschengeist.controller import userController
from flaschengeist.plugins.auth_ldap import AuthLDAP
from ldap3 import SUBTREE
app = create_app()
with app.app_context():
auth_ldap: AuthLDAP = app.config.get("FG_PLUGINS").get("auth_ldap")
if auth_ldap:
conn = auth_ldap.ldap.connection
if not conn:
conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret)
conn.search(auth_ldap.search_dn, "(uid=*)", SUBTREE, attributes=["uid", "givenName", "sn", "mail"])
ldap_users_response = conn.response
for ldap_user in ldap_users_response:
uid = ldap_user["attributes"]["uid"][0]
userController.find_user(uid)
exit()
raise Exception("auth_ldap not found")
if __name__ == "__main__":
# create the top-level parser
parser = argparse.ArgumentParser()
@ -214,8 +192,5 @@ if __name__ == "__main__":
)
parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*")
parser_ldap_sync = subparsers.add_parser("ldap_sync", help="synch ldap-users with database")
parser_ldap_sync.set_defaults(func=ldap_sync)
args = parser.parse_args()
args.func(args)

View File

@ -20,16 +20,7 @@ class DocsCommand(Command):
def run(self):
"""Run command."""
command = [
"python",
"-m",
"pdoc",
"--skip-errors",
"--html",
"--output-dir",
self.output,
"flaschengeist",
]
command = ["python", "-m", "pdoc", "--skip-errors", "--html", "--output-dir", self.output, "flaschengeist"]
self.announce(
"Running command: %s" % str(command),
)
@ -42,20 +33,15 @@ setup(
scripts=["run_flaschengeist"],
python_requires=">=3.7",
install_requires=[
"Flask >= 2.0",
"Flask >= 1.1",
"toml",
"sqlalchemy>=1.4.26",
"sqlalchemy>=1.4",
"flask_sqlalchemy>=2.5",
"flask_cors",
"Pillow>=8.4.0",
"werkzeug",
mysql_driver,
],
extras_require={
"ldap": ["flask_ldapconn", "ldap3"],
"argon": ["argon2-cffi"],
"test": ["pytest", "coverage"],
},
extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]},
entry_points={
"flaschengeist.plugin": [
# Authentication providers
@ -68,7 +54,7 @@ setup(
"balance = flaschengeist.plugins.balance:BalancePlugin",
"events = flaschengeist.plugins.events:EventPlugin",
"mail = flaschengeist.plugins.message_mail:MailMessagePlugin",
"pricelist = flaschengeist.plugins.pricelist:PriceListPlugin",
"pricelist = flaschengeist.plugins.pricelist:PriceListPlugin [pricelist]",
],
},
cmdclass={

View File

@ -22,13 +22,7 @@ with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f:
@pytest.fixture
def app():
db_fd, db_path = tempfile.mkstemp()
app = create_app(
{
"TESTING": True,
"DATABASE": {"file_path": f"/{db_path}"},
"LOGGING": {"level": "DEBUG"},
}
)
app = create_app({"TESTING": True, "DATABASE": {"file_path": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}})
with app.app_context():
install_all()
engine = database.db.engine