Compare commits

..

1 Commits

Author SHA1 Message Date
Ferdinand Thiessen ce074bb51a [events] Initial work for job transfer 2021-04-04 21:47:04 +02:00
33 changed files with 640 additions and 1989 deletions

2
.gitignore vendored
View File

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

View File

@ -52,11 +52,8 @@ def __load_plugins(app):
app.register_blueprint(plugin.blueprint) app.register_blueprint(plugin.blueprint)
except: except:
logger.error( logger.error(
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True
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"]:
@ -68,7 +65,6 @@ 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():

View File

@ -9,7 +9,7 @@ from flaschengeist import _module_path, logger
# Default config: # Default config:
config = {"DATABASE": {"engine": "mysql", "port": 3306}} config = {"DATABASE": {"port": 3306}}
def update_dict(d, u): def update_dict(d, u):
@ -41,21 +41,17 @@ def read_configuration(test_config):
update_dict(config, test_config) update_dict(config, test_config)
def configure_logger(): def configure_app(app, test_config=None):
global config global config
# Read default config read_configuration(test_config)
logger_config = toml.load(_module_path / "logging.toml")
# 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: if "LOGGING" in config:
# Override with user config
update_dict(logger_config, config.get("LOGGING"))
# Check for shortcuts
if "level" in config["LOGGING"]: if "level" in config["LOGGING"]:
logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]} 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"]: if "file" in config["LOGGING"]:
logger_config["root"]["handlers"].append("file") logger_config["root"]["handlers"].append("file")
logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"] logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"]
@ -63,58 +59,23 @@ def configure_logger():
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
logging.config.dictConfig(logger_config) 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"]: if "secret_key" not in config["FLASCHENGEIST"]:
logger.warning("No secret key was configured, please configure one for production systems!") logger.warning("No secret key was configured, please configure one for production systems!")
app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29" app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29"
else: else:
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"] app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
if test_config is not None: if test_config is None:
config["DATABASE"]["engine"] = "sqlite" app.config["SQLALCHEMY_DATABASE_URI"] = "mysql{driver}://{user}:{passwd}@{host}:{port}/{database}".format(
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"],
password=config["DATABASE"]["password"], passwd=config["DATABASE"]["password"],
host=config["DATABASE"]["host"], host=config["DATABASE"]["host"],
database=config["DATABASE"]["database"],
port=config["DATABASE"]["port"], port=config["DATABASE"]["port"],
) )
app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}" else:
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"]:

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 werkzeug.exceptions import BadRequest, NotFound
from flaschengeist.models.user import Role, Permission 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 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")
else: elif role.name != new_name:
if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar(): if db.session.query(db.exists().where(Role.name == 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()

View File

@ -175,8 +175,8 @@ def register(data):
allowed_keys = User().serialize().keys() allowed_keys = User().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}
roles = values.pop("roles", []) roles = values.pop("roles", [])
if "birthday" in data: if "birthday" in values:
values["birthday"] = from_iso_format(data["birthday"]).date() values["birthday"] = from_iso_format(values["birthday"]).date()
user = User(**values) user = User(**values)
set_roles(user, roles) set_roles(user, roles)
@ -195,8 +195,6 @@ def register(data):
) )
messageController.send_message(messageController.Message(user, text, subject)) messageController.send_message(messageController.Message(user, text, subject))
find_user(user.userid)
return user return user
@ -209,11 +207,6 @@ def save_avatar(user, avatar):
db.session.commit() db.session.commit()
def delete_avatar(user):
current_app.config["FG_AUTH_BACKEND"].delete_avatar(user)
db.session.commit()
def persist(user=None): def persist(user=None):
if user: if user:
db.session.add(user) db.session.add(user)

View File

@ -1,11 +1,3 @@
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

View File

@ -7,52 +7,38 @@ auth = "auth_plain"
# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn
#proxy = false #proxy = false
# Set root path, prefixes all routes # Set root path, prefixes all routes
root = "/api" #root = /api
# Set secret key # Set secret key
secret_key = "V3ryS3cr3t" secret_key = "V3ryS3cr3t"
# Domain used by frontend # Domain used by frontend
#domain = "flaschengeist.local" #domain = "flaschengeist.local"
[LOGGING] [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 # Uncomment to enable logging to a file
# file = "/tmp/flaschengeist-debug.log" #file = "/tmp/flaschengeist-debug.log"
# Uncomment to disable console logging # Logging level, possible: DEBUG INFO WARNING ERROR
# console = False level = "WARNING"
[DATABASE] [DATABASE]
# engine = "mysql" (default) # user = "user"
# host = "127.0.0.1"
[FILES] # password = "password"
# Path for file / image uploads # database = "database"
data_path = "./data"
# Thumbnail size
thumbnail_size = [192, 192]
# Accepted mimetypes
allowed_mimetypes = [
"image/avif",
"image/jpeg",
"image/png",
"image/webp"
]
[auth_plain] [auth_plain]
enabled = true enabled = true
[auth_ldap] #[auth_ldap]
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap # enabled = true
enabled = false # host =
# host = "localhost" # port =
# port = 389 # bind_dn =
# base_dn = "dc=example,dc=com" # base_dn =
# root_dn = "cn=Manager,dc=example,dc=com" # secret =
# root_secret = "SuperS3cret" # use_ssl =
# Uncomment to use secured LDAP (ldaps) # admin_dn =
# use_ssl = true # admin_dn =
# default_gid =
[MESSAGES] [MESSAGES]
welcome_subject = "Welcome to Flaschengeist {name}" 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 version = 1
disable_existing_loggers = false disable_existing_loggers = false
[formatters] [formatters]
[formatters.simple] [formatters.simple]
format = "%(asctime)s - %(levelname)s - %(message)s" format = "%(asctime)s - %(name)s - %(message)s"
[formatters.extended] [formatters.extended]
format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s" format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s"

View File

@ -2,7 +2,7 @@ import sys
import datetime import datetime
from sqlalchemy import BigInteger from sqlalchemy import BigInteger
from sqlalchemy.dialects import mysql, sqlite from sqlalchemy.dialects import mysql
from sqlalchemy.types import DateTime, TypeDecorator from sqlalchemy.types import DateTime, TypeDecorator
@ -44,8 +44,7 @@ class ModelSerializeMixin:
class Serial(TypeDecorator): class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs""" """Same as MariaDB Serial used for IDs"""
cache_ok = True 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):
@ -61,7 +60,6 @@ class UtcDateTime(TypeDecorator):
aware value, even with SQLite or MySQL. aware value, even with SQLite or MySQL.
""" """
cache_ok = True
impl = DateTime(timezone=True) impl = DateTime(timezone=True)
@staticmethod @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)) 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)) mail: str = db.Column(db.String(60), nullable=False)
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
@ -67,9 +67,7 @@ class User(db.Model, ModelSerializeMixin):
sessions_ = db.relationship("Session", back_populates="user_") sessions_ = db.relationship("Session", back_populates="user_")
_attributes = db.relationship( _attributes = db.relationship(
"_UserAttribute", "_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete"
collection_class=attribute_mapped_collection("name"),
cascade="all, delete",
) )
@property @property
@ -94,10 +92,6 @@ class User(db.Model, ModelSerializeMixin):
return self._attributes[name].value return self._attributes[name].value
return default return default
def delete_attribute(self, name):
if name in self._attributes:
self._attributes.pop(name)
def get_permissions(self): def get_permissions(self):
return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions] 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 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
@ -95,7 +94,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.id, user_=user) n = Notification(text=text, data=data, plugin=self.name, user_=user)
db.session.add(n) db.session.add(n)
db.session.commit() db.session.commit()
@ -191,14 +190,3 @@ class AuthPlugin(Plugin):
MethodNotAllowed: If not supported by Backend MethodNotAllowed: If not supported by Backend
""" """
raise MethodNotAllowed 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""" """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 from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5
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, before_role_updated from flaschengeist.plugins import AuthPlugin, after_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, config): def __init__(self, cfg):
super().__init__() super().__init__()
config = {"port": 389, "use_ssl": False}
config.update(cfg)
app.config.update( app.config.update(
LDAP_SERVER=config.get("host", "localhost"), LDAP_SERVER=config["host"],
LDAP_PORT=config.get("port", 389), LDAP_PORT=config["port"],
LDAP_BINDDN=config.get("bind_dn", None), LDAP_BINDDN=config["bind_dn"],
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_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, FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
) )
if "ca_cert" in config: if "secret" in config:
app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"] app.config["LDAP_SECRET"] = config["secret"]
else:
# Default is CERT_REQUIRED
app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL
self.ldap = LDAPConn(app) self.ldap = LDAPConn(app)
self.base_dn = config["base_dn"] self.dn = config["base_dn"]
self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn) self.default_gid = config["default_gid"]
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")
# TODO: might not be set if modify is called # TODO: might not be set if modify is called
self.root_dn = config.get("root_dn", None) if "admin_dn" in config:
self.root_secret = config.get("root_secret", None) 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): 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.base_dn) return self.ldap.authenticate(user.userid, password, "uid", self.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)
@ -69,55 +64,43 @@ 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.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
attributes = self.user_attributes.copy()
if "uidNumber" in attributes:
self.ldap.connection.search( self.ldap.connection.search(
self.search_dn, "ou=user,{}".format(self.dn),
"(uidNumber=*)", "(uidNumber=*)",
SUBTREE, SUBTREE,
attributes=["uidNumber"], attributes=["uidNumber"],
) )
resp = sorted( uid_number = (
self.ldap.response(), sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][
key=lambda i: i["attributes"]["uidNumber"], "attributes"
reverse=True, ]["uidNumber"]
+ 1
) )
attributes["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}"
dn = self.dn_template.format( object_class = [
user=user, "inetOrgPerson",
base_dn=self.base_dn, "posixAccount",
) "person",
if "default_gid" in attributes: "organizationalPerson",
default_gid = attributes.pop("default_gid") ]
attributes["gidNumber"] = default_gid attributes = {
if "homeDirectory" in attributes: "sn": user.firstname,
attributes["homeDirectory"] = attributes.get("homeDirectory").format( "givenName": user.lastname,
firstname=user.firstname, "gidNumber": self.default_gid,
lastname=user.lastname, "homeDirectory": f"/home/{user.userid}",
userid=user.userid, "loginShell": "/bin/bash",
mail=user.mail,
display_name=user.display_name,
)
attributes.update(
{
"sn": user.lastname,
"givenName": user.firstname,
"uid": user.userid, "uid": user.userid,
"userPassword": self.__hash(password), "userPassword": hashed(HASHED_SALTED_MD5, password),
"mail": user.mail, "uidNumber": uid_number,
} }
) ldap_conn.add(dn, object_class, attributes)
if user.display_name:
attributes.update({"displayName": user.display_name})
ldap_conn.add(dn, self.object_classes, attributes)
self._set_roles(user) self._set_roles(user)
self.update_user(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
raise BadRequest raise BadRequest
@ -127,10 +110,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.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError 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 = {} modifier = {}
for name, ldap_name in [ for name, ldap_name in [
("firstname", "givenName"), ("firstname", "givenName"),
@ -141,7 +124,9 @@ 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:
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) ldap_conn.modify(dn, modifier)
self._set_roles(user) self._set_roles(user)
except (LDAPPasswordIsMandatoryError, LDAPBindError): except (LDAPPasswordIsMandatoryError, LDAPBindError):
@ -149,7 +134,7 @@ class AuthLDAP(AuthPlugin):
def get_avatar(self, user): def get_avatar(self, user):
self.ldap.connection.search( self.ldap.connection.search(
self.search_dn, "ou=user,{}".format(self.dn),
"(uid={})".format(user.userid), "(uid={})".format(user.userid),
SUBTREE, SUBTREE,
attributes=["jpegPhoto"], attributes=["jpegPhoto"],
@ -159,14 +144,14 @@ class AuthLDAP(AuthPlugin):
if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0: if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0:
avatar = _Avatar() avatar = _Avatar()
avatar.mimetype = "image/jpeg" avatar.mimetype = "image/jpeg"
avatar.binary = bytearray(r["jpegPhoto"][0]) avatar.binary.extend(r["jpegPhoto"][0])
return avatar return avatar
else: else:
raise NotFound raise NotFound
def set_avatar(self, user, avatar: _Avatar): def set_avatar(self, user, avatar: _Avatar):
if self.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
if avatar.mimetype != "image/jpeg": if avatar.mimetype != "image/jpeg":
@ -187,23 +172,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.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])]}) 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): 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.root_dn, self.root_secret) con = self.ldap.connect(self.admin_dn, self.admin_secret)
con.search( con.search(
self.search_dn, f"ou=user,{self.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"],
@ -227,12 +205,12 @@ class AuthLDAP(AuthPlugin):
role: Role, role: Role,
new_name: Optional[str], new_name: Optional[str],
): ):
if self.root_dn is None: if self.admin_dn is None:
logger.error("root_dn missing in ldap config!") logger.error("admin_dn missing in ldap config!")
raise InternalServerError raise InternalServerError
try: try:
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"]) ldap_conn.search(f"ou=group,{self.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:
@ -240,33 +218,13 @@ class AuthLDAP(AuthPlugin):
else: else:
ldap_conn.delete(dn) ldap_conn.delete(dn)
except LDAPPasswordIsMandatoryError: except (LDAPPasswordIsMandatoryError, LDAPBindError):
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.encode() + salt).digest() + salt).decode()}"
def _get_groups(self, uid): def _get_groups(self, uid):
groups = [] groups = []
self.ldap.connection.search( self.ldap.connection.search(
self.group_dn, "ou=group,{}".format(self.dn),
"(memberUID={})".format(uid), "(memberUID={})".format(uid),
SUBTREE, SUBTREE,
attributes=["cn"], attributes=["cn"],
@ -278,7 +236,7 @@ class AuthLDAP(AuthPlugin):
def _get_all_roles(self): def _get_all_roles(self):
self.ldap.connection.search( self.ldap.connection.search(
self.group_dn, f"ou=group,{self.dn}",
"(cn=*)", "(cn=*)",
SUBTREE, SUBTREE,
attributes=["cn", "gidNumber", "memberUid"], attributes=["cn", "gidNumber", "memberUid"],
@ -287,7 +245,8 @@ class AuthLDAP(AuthPlugin):
def _set_roles(self, user: User): def _set_roles(self, user: User):
try: 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() 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)
@ -296,7 +255,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},{self.group_dn}", f"cn={user_role},ou=group,{self.dn}",
["posixGroup"], ["posixGroup"],
attributes={"gidNumber": gid_number}, attributes={"gidNumber": gid_number},
) )

View File

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

View File

@ -3,13 +3,12 @@
# English: Debit -> from account # English: Debit -> from account
# Credit -> to account # Credit -> to account
from sqlalchemy import func, case, and_ from sqlalchemy import func
from sqlalchemy.ext.hybrid import hybrid_property
from datetime import datetime from datetime import datetime
from werkzeug.exceptions import BadRequest, NotFound, Conflict from werkzeug.exceptions import BadRequest, NotFound, Conflict
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.user import User, _UserAttribute from flaschengeist.models.user import User
from .models import Transaction from .models import Transaction
from . import permissions, BalancePlugin from . import permissions, BalancePlugin
@ -39,114 +38,27 @@ def get_balance(user, start: datetime = None, end: datetime = None):
return credit, debit, credit - debit return credit, debit, credit - debit
def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None): def get_balances(start: datetime = None, end: datetime = None):
class _User(User): debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None)
_debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id]) credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter(
_credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id]) Transaction.receiver_ != None
@hybrid_property
def debit(self):
return sum([cred.amount for cred in self._debit])
@debit.expression
def debit(cls):
a = (
db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._sender_id, Transaction.amount)
.scalar_subquery()
) )
return case([(a, a)], else_=0)
@hybrid_property
def credit(self):
return sum([cred.amount for cred in self._credit])
@credit.expression
def credit(cls):
b = (
db.select(func.sum(Transaction.amount))
.where(cls.id_ == Transaction._receiver_id, Transaction.amount)
.scalar_subquery()
)
return case([(b, b)], else_=0)
@hybrid_property
def limit(self):
return self.get_attribute("balance_limit", None)
@limit.expression
def limit(cls):
return (
db.select(_UserAttribute.value)
.where(and_(cls.id_ == _UserAttribute.user, _UserAttribute.name == "balance_limit"))
.scalar_subquery()
)
def get_debit(self, start: datetime = None, end: datetime = None):
if start and end:
return sum([deb.amount for deb in self._debit if start <= deb.time and deb.time <= end])
if start: if start:
return sum([deb.amount for deb in self._dedit if start <= deb.time]) debit = debit.filter(start <= Transaction.time)
credit = credit.filter(start <= Transaction.time)
if end: if end:
return sum([deb.amount for deb in self._dedit if deb.time <= end]) debit = debit.filter(Transaction.time <= end)
return self.debit credit = credit.filter(Transaction.time <= end)
def get_credit(self, start: datetime = None, end: datetime = None): debit = debit.group_by(Transaction._sender_id).all()
if start and end: credit = credit.group_by(Transaction._receiver_id).all()
return sum([cred.amount for cred in self._credit if start <= cred.time and cred.time <= end])
if start:
return sum([cred.amount for cred in self._credit if start <= cred.time])
if end:
return sum([cred.amount for cred in self._credit if cred.time <= end])
return self.credit
query = _User.query
if start:
q1 = query.join(_User._credit).filter(start <= Transaction.time)
q2 = query.join(_User._debit).filter(start <= Transaction.time)
query = q1.union(q2)
if end:
q1 = query.join(_User._credit).filter(Transaction.time <= end)
q2 = query.join(_User._debit).filter(Transaction.time <= end)
query = q1.union(q2)
if sortBy == "balance":
if descending:
query = query.order_by((_User.credit - _User.debit).desc(), _User.lastname.asc(), _User.firstname.asc())
else:
query = query.order_by((_User.credit - _User.debit).asc(), _User.lastname.asc(), _User.firstname.asc())
elif sortBy == "limit":
if descending:
query = query.order_by(_User.limit.desc(), User.lastname.asc(), User.firstname.asc())
else:
query = query.order_by(_User.limit.asc(), User.lastname.asc(), User.firstname.asc())
elif sortBy == "firstname":
if descending:
query = query.order_by(User.firstname.desc(), User.lastname.desc())
else:
query = query.order_by(User.firstname.asc(), User.lastname.asc())
elif sortBy == "lastname":
if descending:
query = query.order_by(User.lastname.desc(), User.firstname.desc())
else:
query = query.order_by(User.lastname.asc(), User.firstname.asc())
count = None
if limit:
count = query.count()
query = query.limit(limit)
if offset:
query = query.offset(offset)
users = query
all = {} all = {}
for uid, cred in credit:
for user in users: all[uid] = [cred, 0]
for uid, deb in debit:
all[user.userid] = [user.get_credit(start, end), 0] all.setdefault(uid, [0, 0])
all[user.userid][1] = user.get_debit(start, end) all[uid][1] = deb
return all
return all, count
def send(sender: User, receiver, amount: float, author: User): def send(sender: User, receiver, amount: float, author: User):
@ -201,16 +113,7 @@ def get_transaction(transaction_id) -> Transaction:
return transaction return transaction
def get_transactions( def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True):
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:
@ -222,9 +125,6 @@ def get_transactions(
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))
if descending:
query = query.order_by(Transaction.time.desc())
else:
query = query.order_by(Transaction.time) query = query.order_by(Transaction.time)
if limit is not None: if limit is not None:
count = query.count() count = query.count()

View File

@ -99,31 +99,6 @@ 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):
@ -195,7 +170,6 @@ 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)
@ -205,20 +179,11 @@ 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, user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled
start,
end,
limit,
offset,
show_reversal=show_reversals,
show_cancelled=show_cancelled,
descending=descending,
) )
return {"transactions": transactions, "count": count} return {"transactions": transactions, "count": count}
@ -310,14 +275,5 @@ def get_balances(current_session: Session):
Returns: Returns:
JSON Array containing credit, debit and userid for each user or HTTP error JSON Array containing credit, debit and userid for each user or HTTP error
""" """
limit = request.args.get("limit", type=int) balances = balance_controller.get_balances()
offset = request.args.get("offset", type=int) return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()])
descending = request.args.get("descending", False, type=bool)
sortBy = request.args.get("sortBy", type=str)
balances, count = balance_controller.get_balances(limit=limit, offset=offset, descending=descending, sortBy=sortBy)
return jsonify(
{
"balances": [{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()],
"count": count,
}
)

View File

@ -11,7 +11,6 @@ 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__)

View File

@ -1,47 +1,82 @@
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from enum import IntEnum from typing import Optional
from typing import Optional, Tuple
from werkzeug.exceptions import BadRequest, Conflict, NotFound from werkzeug.exceptions import BadRequest, NotFound, Conflict
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.util import was_deleted
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.plugins.events import EventPlugin from flaschengeist.plugins.events import EventPlugin
from flaschengeist.plugins.events.models import EventType, Event, Invitation, Job, JobType, Service from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, JobTransfer, Service
from flaschengeist.utils.scheduler import scheduled from flaschengeist.utils.scheduler import scheduled
# STUB TRANSFER_REQUEST = 0x10
def _(x): TRANSFER_ACCEPTED = 0x11
return x TRANSFER_REJECTED = 0x12
class NotifyType(IntEnum):
# Invitations 0x00..0x0F
INVITE = 0x01
TRANSFER = 0x02
# Invitation responsed 0x10..0x1F
INVITATION_ACCEPTED = 0x10
INVITATION_REJECTED = 0x11
def update(): def update():
db.session.commit() db.session.commit()
def invite(job, invitee, sender):
EventPlugin.plugin.notify(invitee, "Neue Diensteinladung", {"type": 0, "sender": sender.userid, "job": job.id})
def get_transfer(id):
return JobTransfer.query.get(id)
def transfer(job: Job, new, old):
service = Service.query.get((job.id, old.id_))
if service is None:
raise BadRequest
jt = JobTransfer(old_user=old, new_user=new, job=job)
db.session.add(jt)
db.session.commit()
EventPlugin.plugin.notify(
new,
"Neue Dienstübergabe",
{"type": TRANSFER_REQUEST, "sender": old.userid, "event": job.event_id_, "job": job.id, "id": jt.id},
)
def accept_transfer(jt: JobTransfer, accept=True):
try:
if accept:
service = Service.query.get((jt.job.id, jt.old_user.id_))
if service is not None:
service.user_ = jt.new_user
else:
raise Conflict
EventPlugin.plugin.notify(
jt.old_user,
"Dienstübergabe akzeptiert",
{"type": TRANSFER_ACCEPTED, "sender": jt.new_user.userid, "event": jt.job.event_id_, "job": jt.job_id_},
)
else:
EventPlugin.plugin.notify(
jt.old_user,
"Dienstübergabe abgelehnt",
{"type": TRANSFER_REJECTED, "sender": jt.new_user.userid, "event": jt.job.event_id_, "job": jt.job_id_},
)
finally:
db.session.delete(jt)
db.session.commit()
def get_event_types(): def get_event_types():
return EventType.query.all() return EventType.query.all()
def get_event_type(identifier): def get_event_type(identifier):
"""Get EventType by ID (int) or name (string)""" """Get EventType by ID (int)"""
if isinstance(identifier, int): if isinstance(identifier, int):
et = EventType.query.get(identifier) et = EventType.query.get(identifier)
elif isinstance(identifier, str):
et = EventType.query.filter(EventType.name == identifier).one_or_none()
else: else:
logger.debug("Invalid identifier type for EventType") logger.debug("Invalid identifier type for EventType")
raise BadRequest raise BadRequest
@ -57,7 +92,7 @@ def create_event_type(name):
db.session.commit() db.session.commit()
return event return event
except IntegrityError: except IntegrityError:
raise Conflict("Name already exists") raise BadRequest("Name already exists")
def rename_event_type(identifier, new_name): def rename_event_type(identifier, new_name):
@ -66,7 +101,7 @@ def rename_event_type(identifier, new_name):
try: try:
db.session.commit() db.session.commit()
except IntegrityError: except IntegrityError:
raise Conflict("Name already exists") raise BadRequest("Name already exists")
def delete_event_type(name): def delete_event_type(name):
@ -118,11 +153,11 @@ def delete_job_type(name):
raise BadRequest("Type still in use") raise BadRequest("Type still in use")
def clear_backup(event: Event): def clear_backup(event: Event, backup):
for job in event.jobs: for job in event.jobs:
services = [] services = []
for service in job.services: for service in job.services:
if not service.is_backup: if not service.is_backup or service.userid == backup:
services.append(service) services.append(service)
job.services = services job.services = services
@ -131,8 +166,6 @@ def get_event(event_id, with_backup=False) -> Event:
event = Event.query.get(event_id) event = Event.query.get(event_id)
if event is None: if event is None:
raise NotFound raise NotFound
if not with_backup:
clear_backup(event)
return event return event
@ -140,14 +173,7 @@ def get_templates():
return Event.query.filter(Event.is_template == True).all() return Event.query.filter(Event.is_template == True).all()
def get_events( def get_events(start: Optional[datetime] = None, end=None, with_backup=False):
start: Optional[datetime] = None,
end: Optional[datetime] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
descending: Optional[bool] = False,
with_backup=False,
) -> Tuple[int, list[Event]]:
"""Query events which start from begin until end """Query events which start from begin until end
Args: Args:
start (datetime): Earliest start start (datetime): Earliest start
@ -161,23 +187,11 @@ def get_events(
query = query.filter(start <= Event.start) query = query.filter(start <= Event.start)
if end is not None: if end is not None:
query = query.filter(Event.start < end) query = query.filter(Event.start < end)
elif start is None:
# Neither start nor end was given
query = query.filter(datetime.now() <= Event.start)
if descending:
query = query.order_by(Event.start.desc())
else:
query = query.order_by(Event.start)
count = query.count()
if limit is not None:
query = query.limit(limit)
if offset is not None and offset > 0:
query = query.offset(offset)
events = query.all() events = query.all()
if not with_backup: if not (with_backup is True):
for event in events: for event in events:
clear_backup(event) clear_backup(event, with_backup)
return count, events return events
def delete_event(event_id): def delete_event(event_id):
@ -188,9 +202,7 @@ def delete_event(event_id):
Raises: Raises:
NotFound if not found NotFound if not found
""" """
event = get_event(event_id, True) event = get_event(event_id)
for job in event.jobs:
delete_job(job)
db.session.delete(event) db.session.delete(event)
db.session.commit() db.session.commit()
@ -215,42 +227,15 @@ def create_event(event_type, start, end=None, jobs=[], is_template=None, name=No
raise BadRequest raise BadRequest
def get_job(job_id, event_id=None) -> Job: def get_job(job_slot_id, event_id):
query = Job.query.filter(Job.id == job_id) js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none()
if event_id is not None: if js is None:
query = query.filter(Job.event_id_ == event_id)
job = query.one_or_none()
if job is None:
raise NotFound raise NotFound
return job return js
def get_jobs(user, start=None, end=None, limit=None, offset=None, descending=None) -> Tuple[int, list[Job]]:
query = Job.query.join(Service).filter(Service.user_ == user)
if start is not None:
query = query.filter(start <= Job.end)
if end is not None:
query = query.filter(end >= Job.start)
if descending is not None:
query = query.order_by(Job.start.desc(), Job.type_id_)
else:
query = query.order_by(Job.start, Job.type_id_)
count = query.count()
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
return count, query.all()
def add_job(event, job_type, required_services, start, end=None, comment=None): def add_job(event, job_type, required_services, start, end=None, comment=None):
job = Job( job = Job(required_services=required_services, type=job_type, start=start, end=end, comment=comment)
required_services=required_services,
type=job_type,
start=start,
end=end,
comment=comment,
)
event.jobs.append(job) event.jobs.append(job)
update() update()
return job return job
@ -260,111 +245,29 @@ def update():
try: try:
db.session.commit() db.session.commit()
except IntegrityError: except IntegrityError:
logger.debug( logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True)
"Error, looks like a Job with that type already exists on an event",
exc_info=True,
)
raise BadRequest() raise BadRequest()
def delete_job(job: Job): def delete_job(job: Job):
for service in job.services:
unassign_job(service=service, notify=True)
for invitation in job.invitations_:
respond_invitation(invitation, False)
db.session.delete(job) db.session.delete(job)
db.session.commit() db.session.commit()
def assign_job(job: Job, user, value, is_backup=False): def assign_to_job(job: Job, user, value, backup=False):
assert value > 0
service = Service.query.get((job.id, user.id_)) service = Service.query.get((job.id, user.id_))
if service: if value < 0:
service.value = value
else:
job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job))
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: if not service:
raise BadRequest raise BadRequest
event_id = service.job_.event_id_
db.session.delete(service) db.session.delete(service)
db.session.commit()
if notify:
EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id})
def invite(job: Job, invitee, inviter, transferee=None):
inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee)
db.session.add(inv)
update()
if transferee is None:
EventPlugin.plugin.notify(invitee, _("Job invitation"), {"type": NotifyType.INVITE, "invitation": inv.id})
else: else:
EventPlugin.plugin.notify(invitee, _("Job transfer"), {"type": NotifyType.TRANSFER, "invitation": inv.id}) if service:
return inv service.value = value
service.is_backup = backup
def get_invitation(id: int):
inv: Invitation = Invitation.query.get(id)
if inv is None:
raise NotFound
return inv
def cancel_invitation(inv: Invitation):
db.session.delete(inv)
db.session.commit()
def respond_invitation(invite: Invitation, accepted=True):
inviter = invite.inviter_
job = invite.job_
db.session.delete(invite)
db.session.commit()
if not was_deleted(invite):
raise Conflict
if not accepted:
EventPlugin.plugin.notify(
inviter,
_("Invitation rejected"),
{
"type": NotifyType.INVITATION_REJECTED,
"event": job.event_id_,
"job": invite.job_id,
"invitee": invite.invitee_id,
},
)
else: else:
if invite.transferee_id is None: service = Service(user_=user, value=value, job_=job, is_backup=backup)
assign_job(job, invite.invitee_, 1) db.session.add(service)
else: db.session.commit()
service = filter(lambda s: s.userid == invite.transferee_id, job.services)
if not service:
raise Conflict
unassign_job(job, invite.transferee_, service[0], True)
assign_job(job, invite.invitee_, service[0].value)
EventPlugin.plugin.notify(
inviter,
_("Invitation accepted"),
{
"type": NotifyType.INVITATION_ACCEPTED,
"event": job.event_id_,
"job": invite.job_id,
"invitee": invite.invitee_id,
},
)
@scheduled @scheduled
@ -377,9 +280,7 @@ def assign_backups():
for service in services: for service in services:
if service.job_.start <= now or service.job_.is_full(): if service.job_.start <= now or service.job_.is_full():
EventPlugin.plugin.notify( EventPlugin.plugin.notify(
service.user_, service.user_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_}
"Your backup assignment was cancelled.",
{"event_id": service.job_.event_id_},
) )
logger.debug(f"Service is outdated or full, removing. {service.serialize()}") logger.debug(f"Service is outdated or full, removing. {service.serialize()}")
db.session.delete(service) db.session.delete(service)
@ -387,8 +288,6 @@ def assign_backups():
service.is_backup = False service.is_backup = False
logger.debug(f"Service not full, assigning backup. {service.serialize()}") logger.debug(f"Service not full, assigning backup. {service.serialize()}")
EventPlugin.plugin.notify( EventPlugin.plugin.notify(
service.user_, service.user_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_}
"Your backup assignment was accepted.",
{"event_id": service.job_.event_id_},
) )
db.session.commit() db.session.commit()

View File

@ -39,13 +39,7 @@ class Service(db.Model, ModelSerializeMixin):
is_backup: bool = db.Column(db.Boolean, default=False) is_backup: bool = db.Column(db.Boolean, default=False)
value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False) value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False)
_job_id = db.Column( _job_id = db.Column("job_id", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True)
"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_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True)
user_: User = db.relationship("User") user_: User = db.relationship("User")
@ -58,13 +52,13 @@ class Service(db.Model, ModelSerializeMixin):
class Job(db.Model, ModelSerializeMixin): class Job(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "job" __tablename__ = _table_prefix_ + "job"
_type_id = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False)
id: int = db.Column(Serial, primary_key=True) id: int = db.Column(Serial, primary_key=True)
start: datetime = db.Column(UtcDateTime, nullable=False) start: datetime = db.Column(UtcDateTime, nullable=False)
end: Optional[datetime] = db.Column(UtcDateTime) end: Optional[datetime] = db.Column(UtcDateTime)
type: Union[JobType, int] = db.relationship("JobType") type: Union[JobType, int] = db.relationship("JobType")
comment: Optional[str] = db.Column(db.String(256)) comment: Optional[str] = db.Column(db.String(256))
locked: bool = db.Column(db.Boolean(), default=False, nullable=False)
services: list[Service] = db.relationship( services: list[Service] = db.relationship(
"Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan" "Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan"
) )
@ -72,13 +66,21 @@ class Job(db.Model, ModelSerializeMixin):
event_ = db.relationship("Event", back_populates="jobs") event_ = db.relationship("Event", back_populates="jobs")
event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False) event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False)
type_id_ = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False)
invitations_ = db.relationship("Invitation", cascade="all,delete,delete-orphan", back_populates="job_")
__table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),) __table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),)
class JobTransfer(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "job_transfer"
id: int = db.Column(Serial, primary_key=True)
old_id_ = db.Column("old_id", Serial, db.ForeignKey("user.id"), nullable=False)
new_id_ = db.Column("new_id", Serial, db.ForeignKey("user.id"), nullable=False)
job_id_ = db.Column("job_id", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False)
old_user = db.relationship("User", foreign_keys=[old_id_])
new_user = db.relationship("User", foreign_keys=[new_id_])
job = db.relationship("Job")
########## ##########
# Events # # Events #
########## ##########
@ -94,47 +96,33 @@ class Event(db.Model, ModelSerializeMixin):
type: Union[EventType, int] = db.relationship("EventType") type: Union[EventType, int] = db.relationship("EventType")
is_template: bool = db.Column(db.Boolean, default=False) is_template: bool = db.Column(db.Boolean, default=False)
jobs: list[Job] = db.relationship( jobs: list[Job] = db.relationship(
"Job", "Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]"
back_populates="event_",
cascade="all,delete,delete-orphan",
order_by="[Job.start, Job.end]",
) )
# Protected for internal use # Protected for internal use
_type_id = db.Column( _type_id = db.Column(
"type_id", "type_id", Serial, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False
Serial,
db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"),
nullable=False,
) )
class Invitation(db.Model, ModelSerializeMixin): class Invite(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "invitation" __tablename__ = _table_prefix_ + "invite"
id: int = db.Column(Serial, primary_key=True) id: int = db.Column(Serial, primary_key=True)
job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False) job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False)
# Dummy properties for API export # Dummy properties for API export
invitee_id: str = None # User who was invited to take over invitee_id: str = None
inviter_id: str = None # User who invited the invitee sender_id: str = None
transferee_id: Optional[str] = None # In case of a transfer: The user who is transfered out of the job
# Not exported properties for backend use # Not exported properties for backend use
job_: Job = db.relationship(Job, foreign_keys="Invitation.job_id") invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id")
invitee_: User = db.relationship("User", foreign_keys="Invitation._invitee_id") sender_: User = db.relationship("User", foreign_keys="Invite._sender_id")
inviter_: User = db.relationship("User", foreign_keys="Invitation._inviter_id")
transferee_: User = db.relationship("User", foreign_keys="Invitation._transferee_id")
# Protected properties needed for internal use # Protected properties needed for internal use
_invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False) _invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False)
_inviter_id = db.Column("inviter_id", Serial, db.ForeignKey("user.id"), nullable=False) _sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id"), nullable=False)
_transferee_id = db.Column("transferee_id", Serial, db.ForeignKey("user.id"))
@property @property
def invitee_id(self): def invitee_id(self):
return self.invitee_.userid return self.invitee_.userid
@property @property
def inviter_id(self): def sender_id(self):
return self.inviter_.userid return self.sender_.userid
@property
def transferee_id(self):
return self.transferee_.userid if self.transferee_ else None

View File

@ -22,7 +22,4 @@ ASSIGN_OTHER = "events_assign_other"
SEE_BACKUP = "events_see_backup" SEE_BACKUP = "events_see_backup"
"""Can see users assigned as 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("_")] permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -1,3 +1,4 @@
from datetime import datetime
from http.client import NO_CONTENT from http.client import NO_CONTENT
from flask import request, jsonify from flask import request, jsonify
from werkzeug.exceptions import BadRequest, NotFound, Forbidden from werkzeug.exceptions import BadRequest, NotFound, Forbidden
@ -8,21 +9,8 @@ from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.controller import userController from flaschengeist.controller import userController
from . import event_controller, permissions, EventPlugin from . import event_controller, permissions, EventPlugin
from ...utils.HTTP import get_filter_args, no_content from ... import logger
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"]) @EventPlugin.blueprint.route("/events/templates", methods=["GET"])
@ -183,7 +171,7 @@ def get_event(event_id, current_session):
""" """
event = event_controller.get_event( event = event_controller.get_event(
event_id, event_id,
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) or current_session.user_.userid,
) )
return jsonify(event) return jsonify(event)
@ -191,17 +179,29 @@ def get_event(event_id, current_session):
@EventPlugin.blueprint.route("/events", methods=["GET"]) @EventPlugin.blueprint.route("/events", methods=["GET"])
@login_required() @login_required()
def get_events(current_session): def get_events(current_session):
count, result = event_controller.get_events( begin = request.args.get("from")
*get_filter_args(), if begin is not None:
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP), 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(
begin,
end,
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP) or current_session.user_.userid,
)
) )
return jsonify({"count": count, "result": result})
def _add_job(event, data): def _add_job(event, data):
try: try:
start = from_iso_format(data["start"]) 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"] required_services = data["required_services"]
job_type = data["type"] job_type = data["type"]
if isinstance(job_type, dict): if isinstance(job_type, dict):
@ -210,14 +210,7 @@ def _add_job(event, data):
raise BadRequest("Missing or invalid POST parameter") raise BadRequest("Missing or invalid POST parameter")
job_type = event_controller.get_job_type(job_type) job_type = event_controller.get_job_type(job_type)
event_controller.add_job( event_controller.add_job(event, job_type, required_services, start, end, comment=data.get("comment", None))
event,
job_type,
required_services,
start,
end,
comment=dict_get(data, "comment", None, str),
)
@EventPlugin.blueprint.route("/events", methods=["POST"]) @EventPlugin.blueprint.route("/events", methods=["POST"])
@ -236,9 +229,11 @@ def create_event(current_session):
JSON encoded Event object or HTTP-error JSON encoded Event object or HTTP-error
""" """
data = request.get_json() data = request.get_json()
end = data.get("end", None)
try: try:
start = from_iso_format(data["start"]) 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"] data_type = data["type"]
if isinstance(data_type, dict): if isinstance(data_type, dict):
data_type = data["type"]["id"] data_type = data["type"]["id"]
@ -251,10 +246,10 @@ def create_event(current_session):
event = event_controller.create_event( event = event_controller.create_event(
start=start, start=start,
end=end, end=end,
name=dict_get(data, "name", None), name=data.get("name", None),
is_template=dict_get(data, "is_template", None), is_template=data.get("is_template", None),
event_type=event_type, event_type=event_type,
description=dict_get(data, "description", None), description=data.get("description", None),
) )
if "jobs" in data: if "jobs" in data:
for job in data["jobs"]: for job in data["jobs"]:
@ -281,14 +276,17 @@ def modify_event(event_id, current_session):
""" """
event = event_controller.get_event(event_id) event = event_controller.get_event(event_id)
data = request.get_json() data = request.get_json()
event.start = dict_get(data, "start", event.start, type=from_iso_format) if "start" in data:
event.end = dict_get(data, "end", event.end, type=from_iso_format) event.start = from_iso_format(data["start"])
event.name = dict_get(data, "name", event.name, type=str) if "end" in data:
event.description = dict_get(data, "description", event.description, type=str) event.end = from_iso_format(data["end"])
if "description" in data:
event.description = data["description"]
if "type" in data: if "type" in data:
event_type = event_controller.get_event_type(data["type"]) event_type = data["type"]
event.type = event_type if isinstance(event_type, dict) and "id" in event_type:
event_type = data["type"]["id"]
event.type = event_controller.get_event_type(event_type)
event_controller.update() event_controller.update()
return jsonify(event) return jsonify(event)
@ -332,12 +330,12 @@ def add_job(event_id, current_session):
return jsonify(event) return jsonify(event)
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["DELETE"]) @EventPlugin.blueprint.route("/events/<int:event_id>/<int:job_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE) @login_required(permission=permissions.DELETE)
def delete_job(event_id, job_id, current_session): def delete_job(event_id, job_id, current_session):
"""Delete a Job """Delete a Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``DELETE`` Route: ``/events/<event_id>/<job_id>`` | Method: ``DELETE``
Args: Args:
event_id: Identifier of the event event_id: Identifier of the event
@ -347,19 +345,19 @@ def delete_job(event_id, job_id, current_session):
Returns: Returns:
HTTP-no-content or HTTP error HTTP-no-content or HTTP error
""" """
job = event_controller.get_job(job_id, event_id) job_slot = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job) event_controller.delete_job(job_slot)
return no_content() return no_content()
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"]) @EventPlugin.blueprint.route("/events/<int:event_id>/<int:job_id>", methods=["PUT"])
@login_required() @login_required(permission=permissions.ASSIGN)
def update_job(event_id, job_id, current_session: Session): 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`` Route: ``/events/<event_id>/<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: Args:
event_id: Identifier of the event event_id: Identifier of the event
@ -369,160 +367,55 @@ def update_job(event_id, job_id, current_session: Session):
Returns: Returns:
JSON encoded Job object or HTTP-error JSON encoded Job object or HTTP-error
""" """
job = event_controller.get_job(job_id, event_id)
data = request.get_json()
if not data or ("user" not in data and "required_services" not in data):
raise BadRequest
# Check if number of services changed, check permission if so
if "required_services" in data:
if not current_session.user_.has_permission(permissions.EDIT): if not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden raise Forbidden
job.required_services = data["required_services"]
data = request.get_json() if "user" in data:
if not data: try:
user = userController.get_user(data["user"]["userid"])
value = data["user"].get("value", None)
replace = data["user"].get("replace", None)
if replace is not None:
replace = userController.get_user(replace)
event_controller.transfer(job, user, replace)
else:
if value is not None:
if user != current_session.user_ and not current_session.user_.has_permission(
permissions.ASSIGN_OTHER
):
raise Forbidden
event_controller.assign_to_job(job, user, value, data["user"].get("is_backup", False))
else:
logger.debug("Invite user")
event_controller.invite(job, user, current_session.user_)
except (KeyError, ValueError, NotFound):
raise BadRequest 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() event_controller.update()
except NotFound:
raise BadRequest("Invalid JobType")
except ValueError:
raise BadRequest("Invalid POST data")
return jsonify(job) return jsonify(job)
@EventPlugin.blueprint.route("/events/jobs", methods=["GET"]) @EventPlugin.blueprint.route("/events/transfer/<int:transfer_id>", methods=["PUT", "DELETE"])
@login_required() @login_required(permission=permissions.ASSIGN)
def get_jobs(current_session: Session): def transfer_accepted(transfer_id, current_session: Session):
count, result = event_controller.get_jobs(current_session.user_, *get_filter_args()) jt = event_controller.get_transfer(transfer_id)
return jsonify({"count": count, "result": result}) if jt is None:
raise NotFound
if request.method == "PUT":
@EventPlugin.blueprint.route("/events/jobs/<int:job_id>/assign", methods=["POST"]) if jt.new_user.userid != current_session.userid:
@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:
JSON encoded Job 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 raise Forbidden
if value > 0: event_controller.accept_transfer(jt)
event_controller.assign_job(job, user, value, data.get("is_backup", False))
else: else:
event_controller.unassign_job(job, user, notify=user != current_session.user_) if jt.new_user.userid != current_session.userid or jt.old_user.userid != current_session.userid:
except (TypeError, KeyError, ValueError): raise Forbidden
raise BadRequest event_controller.accept_transfer(jt, False)
return jsonify(job)
@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()
@EventPlugin.blueprint.route("/events/invitations", methods=["POST"])
@login_required()
def invite(current_session: Session):
"""Invite an user to a job or transfer job
Route: ``/events/invites`` | Method: ``POST``
POST-data: ``{job: number, invitees: string[], is_transfer?: boolean}``
Args:
current_session: Session sent with Authorization Header
Returns:
List of Invitation objects or HTTP-error
"""
data = request.get_json()
transferee = data.get("transferee", None)
if (
transferee is not None
and transferee != current_session.userid
and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
):
raise Forbidden
try:
job = event_controller.get_job(data["job"])
if not isinstance(data["invitees"], list):
raise BadRequest
return jsonify(
[
event_controller.invite(job, invitee, current_session.user_, transferee)
for invitee in [userController.get_user(uid) for uid in data["invitees"]]
]
)
except (TypeError, KeyError, ValueError):
raise BadRequest
@EventPlugin.blueprint.route("/events/invitations/<int:invitation_id>", methods=["GET"])
@login_required()
def get_invitation(invitation_id: int, current_session: Session):
inv = event_controller.get_invitation(invitation_id)
if current_session.userid not in [inv.invitee_id, inv.inviter_id, inv.transferee_id]:
raise Forbidden
return jsonify(inv)
@EventPlugin.blueprint.route("/events/invitations/<int:invitation_id>", methods=["DELETE", "PUT"])
@login_required()
def respond_invitation(invitation_id: int, current_session: Session):
inv = event_controller.get_invitation(invitation_id)
if request.method == "DELETE":
if current_session.userid == inv.invitee_id:
event_controller.respond_invitation(inv, False)
elif current_session.userid == inv.inviter_id:
event_controller.cancel_invitation(inv)
else:
raise Forbidden
else:
# maybe validate data is something like ({accepted: true})
if current_session.userid != inv.invitee_id:
raise Forbidden
event_controller.respond_invitation(inv)
return no_content() return no_content()

View File

@ -2,14 +2,13 @@
from flask import Blueprint, jsonify, request, current_app from flask import Blueprint, jsonify, request, current_app
from werkzeug.local import LocalProxy from werkzeug.local import LocalProxy
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized from werkzeug.exceptions import BadRequest, Forbidden
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.plugins import Plugin
from flaschengeist.utils.decorators import login_required, extract_session, headers from flaschengeist.utils.decorators import login_required
from flaschengeist.utils.HTTP import no_content from flaschengeist.utils.HTTP import no_content
from flaschengeist.models.session import Session
from flaschengeist.controller import userController
from . import models from . import models
from . import pricelist_controller, permissions from . import pricelist_controller, permissions
@ -17,7 +16,6 @@ from . import pricelist_controller, permissions
class PriceListPlugin(Plugin): class PriceListPlugin(Plugin):
name = "pricelist" name = "pricelist"
permissions = permissions.permissions
blueprint = Blueprint(name, __name__, url_prefix="/pricelist") blueprint = Blueprint(name, __name__, url_prefix="/pricelist")
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name]) plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name])
models = models models = models
@ -31,17 +29,6 @@ class PriceListPlugin(Plugin):
@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"]) @PriceListPlugin.blueprint.route("/drink-types", methods=["GET"])
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["GET"]) @PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["GET"])
def get_drink_types(identifier=None): def get_drink_types(identifier=None):
"""Get DrinkType(s)
Route: ``/pricelist/drink-types`` | Method: ``GET``
Route: ``/pricelist/drink-types/<identifier>`` | Method: ``GET``
Args:
identifier: If querying a spicific DrinkType
Returns:
JSON encoded (list of) DrinkType(s) or HTTP-error
"""
if identifier is None: if identifier is None:
result = pricelist_controller.get_drink_types() result = pricelist_controller.get_drink_types()
else: else:
@ -52,18 +39,6 @@ def get_drink_types(identifier=None):
@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"]) @PriceListPlugin.blueprint.route("/drink-types", methods=["POST"])
@login_required(permission=permissions.CREATE_TYPE) @login_required(permission=permissions.CREATE_TYPE)
def new_drink_type(current_session): def new_drink_type(current_session):
"""Create new DrinkType
Route ``/pricelist/drink-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded DrinkType or HTTP-error
"""
data = request.get_json() data = request.get_json()
if "name" not in data: if "name" not in data:
raise BadRequest raise BadRequest
@ -74,19 +49,6 @@ def new_drink_type(current_session):
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["PUT"]) @PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TYPE) @login_required(permission=permissions.EDIT_TYPE)
def update_drink_type(identifier, current_session): def update_drink_type(identifier, current_session):
"""Modify DrinkType
Route ``/pricelist/drink-types/<identifier>`` | METHOD ``PUT``
POST-data: ``{name: string}``
Args:
identifier: Identifier of DrinkType
current_session: Session sent with Authorization Header
Returns:
JSON encoded DrinkType or HTTP-error
"""
data = request.get_json() data = request.get_json()
if "name" not in data: if "name" not in data:
raise BadRequest raise BadRequest
@ -97,17 +59,6 @@ def update_drink_type(identifier, current_session):
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TYPE) @login_required(permission=permissions.DELETE_TYPE)
def delete_drink_type(identifier, current_session): def delete_drink_type(identifier, current_session):
"""Delete DrinkType
Route: ``/pricelist/drink-types/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of DrinkType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_drink_type(identifier) pricelist_controller.delete_drink_type(identifier)
return no_content() return no_content()
@ -115,17 +66,6 @@ def delete_drink_type(identifier, current_session):
@PriceListPlugin.blueprint.route("/tags", methods=["GET"]) @PriceListPlugin.blueprint.route("/tags", methods=["GET"])
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["GET"]) @PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["GET"])
def get_tags(identifier=None): def get_tags(identifier=None):
"""Get Tag(s)
Route: ``/pricelist/tags`` | Method: ``GET``
Route: ``/pricelist/tags/<identifier>`` | Method: ``GET``
Args:
identifier: Identifier of Tag
Returns:
JSON encoded (list of) Tag(s) or HTTP-error
"""
if identifier: if identifier:
result = pricelist_controller.get_tag(identifier) result = pricelist_controller.get_tag(identifier)
else: else:
@ -136,58 +76,26 @@ def get_tags(identifier=None):
@PriceListPlugin.blueprint.route("/tags", methods=["POST"]) @PriceListPlugin.blueprint.route("/tags", methods=["POST"])
@login_required(permission=permissions.CREATE_TAG) @login_required(permission=permissions.CREATE_TAG)
def new_tag(current_session): def new_tag(current_session):
"""Create Tag
Route: ``/pricelist/tags`` | Method: ``POST``
POST-data: ``{name: string, color: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded Tag or HTTP-error
"""
data = request.get_json() data = request.get_json()
drink_type = pricelist_controller.create_tag(data) if "name" not in data:
raise BadRequest
drink_type = pricelist_controller.create_tag(data["name"])
return jsonify(drink_type) return jsonify(drink_type)
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["PUT"]) @PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TAG) @login_required(permission=permissions.EDIT_TAG)
def update_tag(identifier, current_session): def update_tag(identifier, current_session):
"""Modify Tag
Route: ``/pricelist/tags/<identifier>`` | Methods: ``PUT``
POST-data: ``{name: string, color: string}``
Args:
identifier: Identifier of Tag
current_session: Session sent with Authorization Header
Returns:
JSON encoded Tag or HTTP-error
"""
data = request.get_json() data = request.get_json()
tag = pricelist_controller.update_tag(identifier, data) if "name" not in data:
raise BadRequest
tag = pricelist_controller.rename_tag(identifier, data["name"])
return jsonify(tag) return jsonify(tag)
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TAG) @login_required(permission=permissions.DELETE_TAG)
def delete_tag(identifier, current_session): def delete_tag(identifier, current_session):
"""Delete Tag
Route: ``/pricelist/tags/<identifier>`` | Methods: ``DELETE``
Args:
identifier: Identifier of Tag
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_tag(identifier) pricelist_controller.delete_tag(identifier)
return no_content() return no_content()
@ -195,417 +103,84 @@ def delete_tag(identifier, current_session):
@PriceListPlugin.blueprint.route("/drinks", methods=["GET"]) @PriceListPlugin.blueprint.route("/drinks", methods=["GET"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["GET"]) @PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["GET"])
def get_drinks(identifier=None): def get_drinks(identifier=None):
"""Get Drink(s)
Route: ``/pricelist/drinks`` | Method: ``GET``
Route: ``/pricelist/drinks/<identifier>`` | Method: ``GET``
Args:
identifier: Identifier of Drink
Returns:
JSON encoded (list of) Drink(s) or HTTP-error
"""
public = True
try:
extract_session()
public = False
except Unauthorized:
public = True
if identifier: if identifier:
result = pricelist_controller.get_drink(identifier, public=public) result = pricelist_controller.get_drink(identifier)
return jsonify(result)
else: else:
limit = request.args.get("limit") result = pricelist_controller.get_drinks()
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,
)
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})
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"]) @PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
def search_drinks(name): def search_drinks(name):
"""Search Drink return jsonify(pricelist_controller.get_drinks(name))
Route: ``/pricelist/drinks/search/<name>`` | Method: ``GET``
Args:
name: Name to search
Returns:
JSON encoded list of Drinks or HTTP-error
"""
public = True
try:
extract_session()
public = False
except Unauthorized:
public = True
return jsonify(pricelist_controller.get_drinks(name, public=public))
@PriceListPlugin.blueprint.route("/drinks", methods=["POST"]) @PriceListPlugin.blueprint.route("/drinks", methods=["POST"])
@login_required(permission=permissions.CREATE) @login_required(permission=permissions.CREATE)
def create_drink(current_session): def create_drink(current_session):
"""Create Drink
Route: ``/pricelist/drinks`` | Method: ``POST``
POST-data :
``{
article_id?: string
cost_per_package?: float,
cost_per_volume?: float,
name: string,
package_size?: number,
receipt?: list[string],
tags?: list[Tag],
type: DrinkType,
uuid?: string,
volume?: float,
volumes?: list[
{
ingredients?: list[{
id: int
drink_ingredient?: {
ingredient_id: int,
volume: float
},
extra_ingredient?: {
id: number,
}
}],
prices?: list[
{
price: float
public: boolean
}
],
volume: float
}
]
}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded Drink or HTTP-error
"""
data = request.get_json() data = request.get_json()
return jsonify(pricelist_controller.set_drink(data)) return jsonify(pricelist_controller.set_drink(data))
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["PUT"]) @PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT) def update_drink(identifier):
def update_drink(identifier, current_session):
"""Modify Drink
Route: ``/pricelist/drinks/<identifier>`` | Method: ``PUT``
POST-data :
``{
article_id?: string
cost_per_package?: float,
cost_per_volume?: float,
name: string,
package_size?: number,
receipt?: list[string],
tags?: list[Tag],
type: DrinkType,
uuid?: string,
volume?: float,
volumes?: list[
{
ingredients?: list[{
id: int
drink_ingredient?: {
ingredient_id: int,
volume: float
},
extra_ingredient?: {
id: number,
}
}],
prices?: list[
{
price: float
public: boolean
}
],
volume: float
}
]
}``
Args:
identifier: Identifier of Drink
current_session: Session sent with Authorization Header
Returns:
JSON encoded Drink or HTTP-error
"""
data = request.get_json() data = request.get_json()
logger.debug(f"update drink {data}")
return jsonify(pricelist_controller.update_drink(identifier, data)) return jsonify(pricelist_controller.update_drink(identifier, data))
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE) def delete_drink(identifier):
def delete_drink(identifier, current_session):
"""Delete Drink
Route: ``/pricelist/drinks/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of Drink
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_drink(identifier) pricelist_controller.delete_drink(identifier)
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/prices/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/prices/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_PRICE) def delete_price(identifier):
def delete_price(identifier, current_session):
"""Delete Price
Route: ``/pricelist/prices/<identifier>`` | Methods: ``DELETE``
Args:
identifier: Identiefer of Price
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_price(identifier) pricelist_controller.delete_price(identifier)
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/volumes/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/volumes/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_VOLUME) def delete_volume(identifier):
def delete_volume(identifier, current_session):
"""Delete DrinkPriceVolume
Route: ``/pricelist/volumes/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of DrinkPriceVolume
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_volume(identifier) pricelist_controller.delete_volume(identifier)
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"]) @PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"])
@login_required() def get_extra_ingredients():
def get_extra_ingredients(current_session):
"""Get ExtraIngredients
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of ExtraIngredients or HTTP-error
"""
return jsonify(pricelist_controller.get_extra_ingredients()) return jsonify(pricelist_controller.get_extra_ingredients())
@PriceListPlugin.blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_INGREDIENTS_DRINK) def delete_ingredient(identifier):
def delete_ingredient(identifier, current_session):
"""Delete Ingredient
Route: ``/pricelist/ingredients/<identifier>`` | Method: ``DELETE``
Args:
identifier: Identifier of Ingredient
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_ingredient(identifier) pricelist_controller.delete_ingredient(identifier)
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"]) @PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"])
@login_required(permission=permissions.EDIT_INGREDIENTS) def set_extra_ingredient():
def set_extra_ingredient(current_session):
"""Create ExtraIngredient
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``POST``
POST-data: ``{ name: string, price: float }``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded ExtraIngredient or HTTP-error
"""
data = request.get_json() data = request.get_json()
return jsonify(pricelist_controller.set_extra_ingredient(data)) return jsonify(pricelist_controller.set_extra_ingredient(data))
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"]) @PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_INGREDIENTS) def update_extra_ingredient(identifier):
def update_extra_ingredient(identifier, current_session):
"""Modify ExtraIngredient
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``PUT``
POST-data: ``{ name: string, price: float }``
Args:
identifier: Identifier of ExtraIngredient
current_session: Session sent with Authorization Header
Returns:
JSON encoded ExtraIngredient or HTTP-error
"""
data = request.get_json() data = request.get_json()
return jsonify(pricelist_controller.update_extra_ingredient(identifier, data)) return jsonify(pricelist_controller.update_extra_ingredient(identifier, data))
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"]) @PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_INGREDIENTS) def delete_extra_ingredient(identifier):
def delete_extra_ingredient(identifier, current_session):
"""Delete ExtraIngredient
Route: ``/pricelist/ingredients/extraIngredients`` | Method: ``DELETE``
Args:
identifier: Identifier of ExtraIngredient
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
pricelist_controller.delete_extra_ingredient(identifier) pricelist_controller.delete_extra_ingredient(identifier)
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"]) @PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST", "GET"])
@login_required() def pricelist_settings_min_prices():
def get_pricelist_settings_min_prices(current_session): if request.method == "GET":
"""Get MinPrices
Route: ``/pricelist/settings/min_prices`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of MinPrices
"""
# TODO: Handle if no prices are set! # TODO: Handle if no prices are set!
try: return jsonify(PriceListPlugin.plugin.get_setting("min_prices"))
min_prices = PriceListPlugin.plugin.get_setting("min_prices") else:
except KeyError:
min_prices = []
return jsonify(min_prices)
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST"])
@login_required(permission=permissions.EDIT_MIN_PRICES)
def post_pricelist_settings_min_prices(current_session):
"""Create MinPrices
Route: ``/pricelist/settings/min_prices`` | Method: ``POST``
POST-data: ``list[int]``
Args:
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
data = request.get_json() data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, int) for n in data): if not isinstance(data, list) or not all(isinstance(n, int) for n in data):
raise BadRequest raise BadRequest
@ -616,7 +191,7 @@ def post_pricelist_settings_min_prices(current_session):
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"]) @PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
@login_required() @login_required()
def get_columns(userid, current_session): def get_columns(userid, current_session: Session):
"""Get pricecalc_columns of an user """Get pricecalc_columns of an user
Route: ``/users/<userid>/pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT`` Route: ``/users/<userid>/pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT``
@ -644,113 +219,3 @@ def get_columns(userid, current_session):
user.set_attribute("pricecalc_columns", data) user.set_attribute("pricecalc_columns", data)
userController.persist() userController.persist()
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns_order", methods=["GET", "PUT"])
@login_required()
def get_columns_order(userid, current_session):
"""Get pricecalc_columns_order of an user
Route: ``/users/<userid>/pricelist/pricecac_columns_order`` | Method: ``GET`` or ``PUT``
POST-data: On ``PUT`` json encoded array of floats
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
GET: JSON object containing the shortcuts as object array or HTTP error
PUT: HTTP-created or HTTP error
"""
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("pricecalc_columns_order", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, str) for mop in data for n in mop.values()):
raise BadRequest
user.set_attribute("pricecalc_columns_order", data)
userController.persist()
return no_content()
@PriceListPlugin.blueprint.route("/users/<userid>/pricelist", methods=["GET", "PUT"])
@login_required()
def get_priclist_setting(userid, current_session):
"""Get pricelistsetting of an user
Route: ``/pricelist/user/<userid>/pricelist`` | Method: ``GET`` or ``PUT``
POST-data: on ``PUT`` ``{value: boolean}``
Args:
userid: Userid identifying the user
current_session: Session sent wth Authorization Header
Returns:
GET: JSON object containing the value as boolean or HTTP-error
PUT: HTTP-NoContent or HTTP-error
"""
if userid != current_session.user_.userid:
raise Forbidden
user = userController.get_user(userid)
if request.method == "GET":
return jsonify(user.get_attribute("pricelist_view", {"value": False}))
else:
data = request.get_json()
if not isinstance(data, dict) or not "value" in data or not isinstance(data["value"], bool):
raise BadRequest
user.set_attribute("pricelist_view", data)
userController.persist()
return no_content()
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "DELETE"])
@login_required(permission=permissions.EDIT)
def set_picture(identifier, current_session):
"""Get, Create, Delete Drink Picture
Route: ``/pricelist/<identifier>/picture`` | Method: ``GET,POST,DELETE``
POST-data: (if remaining) ``Form-Data: mime: 'image/*'``
Args:
identifier: Identifier of Drink
current_session: Session sent with Authorization
Returns:
Picture or HTTP-error
"""
if request.method == "DELETE":
pricelist_controller.delete_drink_picture(identifier)
return no_content()
file = request.files.get("file")
if file:
return jsonify(pricelist_controller.save_drink_picture(identifier, file))
else:
raise BadRequest
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["GET"])
# @headers({"Cache-Control": "private, must-revalidate"})
def _get_picture(identifier):
"""Get Picture
Args:
identifier: Identifier of Drink
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

View File

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

View File

@ -10,18 +10,6 @@ DELETE = "drink_delete"
CREATE_TAG = "drink_tag_create" CREATE_TAG = "drink_tag_create"
"""Can create and edit Tags""" """Can create and edit Tags"""
EDIT_PRICE = "edit_price"
DELETE_PRICE = "delete_price"
EDIT_VOLUME = "edit_volume"
DELETE_VOLUME = "delete_volume"
EDIT_INGREDIENTS_DRINK = "edit_ingredients_drink"
DELETE_INGREDIENTS_DRINK = "delete_ingredients_drink"
EDIT_INGREDIENTS = "edit_ingredients"
DELETE_INGREDIENTS = "delete_ingredients"
EDIT_TAG = "drink_tag_edit" EDIT_TAG = "drink_tag_edit"
DELETE_TAG = "drink_tag_delete" DELETE_TAG = "drink_tag_delete"
@ -32,6 +20,4 @@ EDIT_TYPE = "drink_type_edit"
DELETE_TYPE = "drink_type_delete" DELETE_TYPE = "drink_type_delete"
EDIT_MIN_PRICES = "edit_min_prices"
permissions = [value for key, value in globals().items() if not key.startswith("_")] permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -1,23 +1,13 @@
from werkzeug.exceptions import BadRequest, NotFound from werkzeug.exceptions import BadRequest, NotFound
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from uuid import uuid4
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.utils.decorators import extract_session from flaschengeist.utils.picture import save_picture, get_picture
from .models import ( from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient
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(): def update():
@ -41,13 +31,9 @@ def get_tag(identifier):
return ret return ret
def create_tag(data): def create_tag(name):
try: try:
if "id" in data: tag = Tag(name=name)
data.pop("id")
allowed_keys = Tag().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys}
tag = Tag(**values)
db.session.add(tag) db.session.add(tag)
update() update()
return tag return tag
@ -55,12 +41,9 @@ def create_tag(data):
raise BadRequest("Name already exists") raise BadRequest("Name already exists")
def update_tag(identifier, data): def rename_tag(identifier, new_name):
tag = get_tag(identifier) tag = get_tag(identifier)
allowed_keys = Tag().serialize().keys() tag.name = new_name
values = {key: value for key, value in data.items() if key in allowed_keys}
for key, value in values.items():
setattr(tag, key, value)
try: try:
update() update()
except IntegrityError: except IntegrityError:
@ -122,164 +105,13 @@ def delete_drink_type(identifier):
raise BadRequest("DrinkType still in use") raise BadRequest("DrinkType still in use")
def _create_public_drink(drink): def get_drinks(name=None):
_volumes = []
for volume in drink.volumes:
_prices = []
for price in volume.prices:
price: DrinkPrice
if price.public:
_prices.append(price)
volume.prices = _prices
if len(volume.prices) > 0:
_volumes.append(volume)
drink.volumes = _volumes
if len(drink.volumes) > 0:
return 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
if name: if name:
query = Drink.query.filter(Drink.name.contains(name)) return Drink.query.filter(Drink.name.contains(name)).all()
else: return Drink.query.all()
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 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( def get_drink(identifier):
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
def get_drink(identifier, public=False):
drink = None
if isinstance(identifier, int): if isinstance(identifier, int):
drink = Drink.query.get(identifier) drink = Drink.query.get(identifier)
elif isinstance(identifier, str): elif isinstance(identifier, str):
@ -288,11 +120,6 @@ def get_drink(identifier, public=False):
raise BadRequest("Invalid identifier type for Drink") raise BadRequest("Invalid identifier type for Drink")
if drink is None: if drink is None:
raise NotFound raise NotFound
if public:
return _create_public_drink(drink)
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drink return drink
@ -302,17 +129,11 @@ def set_drink(data):
def update_drink(identifier, data): def update_drink(identifier, data):
try: try:
session = extract_session()
if "id" in data: if "id" in data:
data.pop("id") data.pop("id")
volumes = data.pop("volumes") if "volumes" in data else None volumes = data.pop("volumes") if "volumes" in data else None
tags = []
if "tags" in data: if "tags" in data:
_tags = data.pop("tags") data.pop("tags")
if isinstance(_tags, list):
for _tag in _tags:
if isinstance(_tag, dict) and "id" in _tag:
tags.append(get_tag(_tag["id"]))
drink_type = data.pop("type") drink_type = data.pop("type")
if isinstance(drink_type, dict) and "id" in drink_type: if isinstance(drink_type, dict) and "id" in drink_type:
drink_type = drink_type["id"] drink_type = drink_type["id"]
@ -323,33 +144,24 @@ def update_drink(identifier, data):
else: else:
drink = get_drink(identifier) drink = get_drink(identifier)
for key, value in data.items(): 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) setattr(drink, key, value if value != "" else None)
if drink_type: if drink_type:
drink.type = drink_type drink.type = drink_type
if volumes is not None and session.user_.has_permission(EDIT_VOLUME): if volumes is not None:
drink._volumes = [] set_volumes(volumes, drink)
drink._volumes = set_volumes(volumes)
if len(tags) > 0:
drink.tags = tags
db.session.commit() db.session.commit()
for volume in drink._volumes:
volume.prices = volume._prices
drink.volumes = drink._volumes
return drink return drink
except (NotFound, KeyError): except (NotFound, KeyError):
raise BadRequest raise BadRequest
def set_volumes(volumes): def set_volumes(volumes, drink):
retVal = []
if not isinstance(volumes, list): if not isinstance(volumes, list):
raise BadRequest raise BadRequest
for volume in volumes: for volume in volumes:
retVal.append(set_volume(volume)) drink.volumes.append(set_volume(volume))
return retVal
def delete_drink(identifier): def delete_drink(identifier):
@ -369,7 +181,6 @@ def get_volumes(drink_id=None):
def set_volume(data): def set_volume(data):
session = extract_session()
allowed_keys = DrinkPriceVolume().serialize().keys() allowed_keys = DrinkPriceVolume().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}
prices = None prices = None
@ -378,13 +189,20 @@ def set_volume(data):
prices = values.pop("prices") prices = values.pop("prices")
if "ingredients" in values: if "ingredients" in values:
ingredients = values.pop("ingredients") ingredients = values.pop("ingredients")
values.pop("id", None) vol_id = values.pop("id", None)
if vol_id < 0:
volume = DrinkPriceVolume(**values) volume = DrinkPriceVolume(**values)
db.session.add(volume) 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): if prices:
set_prices(prices, volume) set_prices(prices, volume)
if ingredients and session.user_.has_permission(EDIT_INGREDIENTS_DRINK): if ingredients:
set_ingredients(ingredients, volume) set_ingredients(ingredients, volume)
return volume return volume
@ -395,7 +213,7 @@ def set_prices(prices, volume):
for _price in prices: for _price in prices:
price = set_price(_price) price = set_price(_price)
_prices.append(price) _prices.append(price)
volume._prices = _prices volume.prices = _prices
def set_ingredients(ingredients, volume): def set_ingredients(ingredients, volume):
@ -426,13 +244,18 @@ def get_prices(volume_id=None):
def set_price(data): def set_price(data):
allowed_keys = list(DrinkPrice().serialize().keys()) allowed_keys = DrinkPrice().serialize().keys()
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 = {key: value for key, value in data.items() if key in allowed_keys}
values.pop("id", -1) price_id = values.pop("id", -1)
if price_id < 0:
price = DrinkPrice(**values) price = DrinkPrice(**values)
db.session.add(price) 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 return price
@ -446,13 +269,16 @@ 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: ingredient_id = values.pop("id", -1)
values.pop("cost_per_volume") if ingredient_id < 0:
if "name" in values:
values.pop("name")
values.pop("id", -1)
drink_ingredient = DrinkIngredient(**values) drink_ingredient = DrinkIngredient(**values)
db.session.add(drink_ingredient) 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 return drink_ingredient
@ -467,9 +293,14 @@ def set_ingredient(data):
drink_ingredient_value = data.pop("drink_ingredient") drink_ingredient_value = data.pop("drink_ingredient")
if "extra_ingredient" in data: if "extra_ingredient" in data:
extra_ingredient_value = data.pop("extra_ingredient") extra_ingredient_value = data.pop("extra_ingredient")
data.pop("id", -1) ingredient_id = data.pop("id", -1)
if ingredient_id < 0:
ingredient = Ingredient(**data) ingredient = Ingredient(**data)
db.session.add(ingredient) db.session.add(ingredient)
else:
ingredient = get_ingredient(ingredient_id)
if not ingredient:
raise NotFound
if drink_ingredient_value: if drink_ingredient_value:
ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value) ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value)
if extra_ingredient_value: if extra_ingredient_value:
@ -525,16 +356,17 @@ def delete_extra_ingredient(identifier):
def save_drink_picture(identifier, file): def save_drink_picture(identifier, file):
drink = delete_drink_picture(identifier)
drink.image_ = image_controller.upload_image(file)
db.session.commit()
return drink
def delete_drink_picture(identifier):
drink = get_drink(identifier) drink = get_drink(identifier)
if drink.image_: if not drink.uuid:
db.session.delete(drink.image_) drink.uuid = str(uuid4())
drink.image_ = None
db.session.commit() db.session.commit()
return drink path = config["pricelist"]["path"]
save_picture(file, f"{path}/{drink.uuid}")
def get_drink_picture(identifier, size=None):
drink = get_drink(identifier)
if not drink.uuid:
raise BadRequest
path = config["pricelist"]["path"]
return get_picture(f"{path}/{drink.uuid}")

View File

@ -75,7 +75,7 @@ def list_users(current_session):
@UsersPlugin.blueprint.route("/users/<userid>", methods=["GET"]) @UsersPlugin.blueprint.route("/users/<userid>", methods=["GET"])
@login_required() @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): def get_user(userid, current_session):
"""Retrieve user by userid """Retrieve user by userid
@ -144,16 +144,6 @@ def set_avatar(userid, current_session):
raise BadRequest 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"]) @UsersPlugin.blueprint.route("/users/<userid>", methods=["DELETE"])
@login_required(permission=permissions.DELETE) @login_required(permission=permissions.DELETE)
def delete_user(userid, current_session): def delete_user(userid, current_session):
@ -241,21 +231,3 @@ 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()

View File

@ -2,24 +2,6 @@ from http.client import NO_CONTENT, CREATED
from flask import make_response, jsonify from flask import make_response, jsonify
from flaschengeist.utils.datetime import from_iso_format
def get_filter_args():
"""
Get filter parameter from request
returns: FROM, TO, LIMIT, OFFSET, DESCENDING
"""
from flask import request
return (
request.args.get("from", type=from_iso_format),
request.args.get("to", type=from_iso_format),
request.args.get("limit", type=int),
request.args.get("offset", type=int),
"descending" in request.args,
)
def no_content(): def no_content():
return make_response(jsonify(""), NO_CONTENT) return make_response(jsonify(""), NO_CONTENT)

View File

@ -0,0 +1,38 @@
import os, sys
from PIL import Image
from flask import Response
from werkzeug.exceptions import BadRequest
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}")
image.show()
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):
if size:
with open(f"{path}/drink-{size}.png", "rb") as file:
image = file.read()
else:
with open(f"{path}/drink.png", "rb") as file:
image = file.read()
response = Response(image, mimetype="image/png")
response.add_etag()
return response

114
readme.md
View File

@ -1,16 +1,9 @@
# Flaschengeist # Flaschengeist
This is the backend of the Flaschengeist.
## Installation ## Installation
### Requirements ### Requirements
- `mysql` or `mariadb` - mysql or mariadb
- maybe `libmariadb` development files[1] - python 3.6+
- 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
@ -37,21 +30,9 @@ 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`
Uncomment and change at least all the database parameters! Change at least 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
@ -60,8 +41,6 @@ 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
@ -74,4 +53,89 @@ Or with html output (open `htmlcov/index.html` in a browser):
$ coverage html $ coverage html
## Development ## Development
Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development). ### Code Style
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"
]
},
)

View File

@ -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:
@ -156,39 +156,15 @@ 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)
if not arguments.no_core:
gen.run(models) gen.run(models)
if arguments.plugins is not None: if arguments.plugins:
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"): for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
if len(arguments.plugins) == 0 or entry_point.name in arguments.plugins:
plg = entry_point.load() plg = entry_point.load()
if hasattr(plg, "models") and plg.models is not None: if hasattr(plg, "models") and plg.models is not None:
gen.run(plg.models) gen.run(plg.models)
gen.write() 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__": if __name__ == "__main__":
# create the top-level parser # create the top-level parser
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -207,15 +183,7 @@ 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( parser_export.add_argument("--plugins", help="Also export plugins", action="store_true")
"--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="*")
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 = parser.parse_args()
args.func(args) args.func(args)

View File

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

View File

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