Compare commits

...

27 Commits

Author SHA1 Message Date
Tim Gröger 9ab84073d0 [balance] add serverside pagination 2021-11-22 15:07:16 +01:00
Tim Gröger 0b94177235 [auth_ldap] fix add displayName when create 2021-11-21 19:45:12 +01:00
Ferdinand Thiessen 04d5b1e83a [events] Allow locking events 2021-11-21 17:58:28 +01:00
Ferdinand Thiessen 51a3a8dfc8 [events] Respect backup assignment 2021-11-21 17:52:24 +01:00
Tim Gröger d75574e078 [auth_ldap] fix create Users 2021-11-21 15:30:49 +01:00
Tim Gröger 0be31d0bfe [auth_ldap] sync ldap_users to Database 2021-11-21 15:11:37 +01:00
Tim Gröger 26d63b7c7d [users][auth_ldap][auth_plain] delete avatar 2021-11-20 22:58:05 +01:00
Tim Gröger f7f27311db [image] bigger filename size 2021-11-19 22:04:33 +01:00
Tim Gröger 00c9da4ff2 Merge remote-tracking branch 'origin/develop' into develop 2021-11-19 20:11:07 +01:00
Ferdinand Thiessen 795475fe15 [pricelist] Delete old images 2021-11-19 13:32:54 +01:00
Ferdinand Thiessen d00c603697 [events] Allow server side pageination 2021-11-18 23:06:03 +01:00
Ferdinand Thiessen 48933cdf5f [core] Minor fixes 2021-11-18 23:02:03 +01:00
Ferdinand Thiessen 7cb31bf60e gitignore 2021-11-18 12:57:18 +01:00
Ferdinand Thiessen 05dc158719 [cleanup] PEP8 cleanup 2021-11-18 12:56:02 +01:00
Tim Gröger 6535aeab2e Merge remote-tracking branch 'origin/develop' into develop 2021-11-16 21:32:25 +01:00
Ferdinand Thiessen 92183a4235 [logging] Enabled overriding logger config by user config 2021-11-16 21:18:06 +01:00
Ferdinand Thiessen c6c41adb02 [logging] Enabled overriding logger config by user config 2021-11-16 14:07:05 +01:00
Ferdinand Thiessen f1d973b446 [deps] Updated flask requirement 2021-11-16 14:06:31 +01:00
Ferdinand Thiessen 45ed9219a4 [logging] Some cleanup and improved configuring using user config 2021-11-16 13:43:47 +01:00
Tim Gröger 0ef9d18ace [auth_ldap][fix] fix loade correct picture 2021-11-16 11:18:00 +01:00
Tim Gröger ae1bf6c54b [auth_ldap][fix] hash ssha from ldap3 2021-11-15 22:38:49 +01:00
Tim Gröger f205291d6d [pricelist][fix] autodeletion of relationship. drinks can be modified 2021-11-15 20:47:14 +01:00
Ferdinand Thiessen 6a9db1b36a [pricelist] Fix minor issues 2021-11-15 17:05:18 +01:00
Ferdinand Thiessen e3d0014e62 [pricelist] Use Serial database type instead of int for IDs 2021-11-15 16:34:58 +01:00
Ferdinand Thiessen a43441e0c5 [pricelist] Use new image controller 2021-11-15 16:34:35 +01:00
Ferdinand Thiessen a6fe921920 [controller] Add controller for handling uploading images 2021-11-15 16:32:24 +01:00
ferfissimo 42e304cf5f Merge pull request 'feature/pricelist' (#15) from feature/pricelist into develop
Reviewed-on: #15
2021-11-15 09:55:09 +00:00
29 changed files with 725 additions and 319 deletions

2
.gitignore vendored
View File

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

View File

@ -52,7 +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.", exc_info=True f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.",
exc_info=True,
) )
del plugin del plugin
continue continue

View File

@ -41,17 +41,21 @@ def read_configuration(test_config):
update_dict(config, test_config) update_dict(config, test_config)
def configure_app(app, test_config=None): def configure_logger():
global config global config
read_configuration(test_config) # Read default config
# Always enable this builtin plugins!
update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}})
logger_config = toml.load(_module_path / "logging.toml") 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"]
@ -59,6 +63,23 @@ def configure_app(app, test_config=None):
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"

View File

@ -0,0 +1,65 @@
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

@ -118,8 +118,12 @@ def modify_user(user, password, new_password=None):
messageController.send_message(messageController.Message(user, text, subject)) messageController.send_message(messageController.Message(user, text, subject))
def get_users(): def get_users(userids=None):
return User.query.all() query = User.query
if userids:
query.filter(User.userid in userids)
query = query.order_by(User.lastname.asc(), User.firstname.asc())
return query.all()
def get_user_by_role(role: Role): def get_user_by_role(role: Role):
@ -175,8 +179,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 values: if "birthday" in data:
values["birthday"] = from_iso_format(values["birthday"]).date() values["birthday"] = from_iso_format(data["birthday"]).date()
user = User(**values) user = User(**values)
set_roles(user, roles) set_roles(user, roles)
@ -195,6 +199,8 @@ 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
@ -207,6 +213,11 @@ 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

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

View File

@ -7,24 +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]
# Uncomment to enable logging to a file # You can override all settings from the logging.toml here
#file = "/tmp/flaschengeist-debug.log" # E.g. override the formatters etc
#
# Logging level, possible: DEBUG INFO WARNING ERROR # Logging level, possible: DEBUG INFO WARNING ERROR
level = "WARNING" level = "DEBUG"
# Uncomment to enable logging to a file
# file = "/tmp/flaschengeist-debug.log"
# Uncomment to disable console logging
# console = False
[DATABASE] [DATABASE]
# engine = "mysql" (default) # 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

View File

@ -1,9 +1,12 @@
# 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 - %(name)s - %(message)s" format = "%(asctime)s - %(levelname)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

@ -44,7 +44,7 @@ class ModelSerializeMixin:
class Serial(TypeDecorator): class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs""" """Same as MariaDB Serial used for IDs"""
cache_ok=True cache_ok = True
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite") impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite")
@ -61,6 +61,7 @@ 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

@ -0,0 +1,31 @@
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

@ -67,7 +67,9 @@ 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", collection_class=attribute_mapped_collection("name"), cascade="all, delete" "_UserAttribute",
collection_class=attribute_mapped_collection("name"),
cascade="all, delete",
) )
@property @property
@ -92,12 +94,21 @@ 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]
def has_permission(self, permission): def has_permission(self, permission):
return permission in self.get_permissions() return permission in self.get_permissions()
def __repr__(self):
return (
f"User({self.userid}, {self.firstname}, {self.lastname}, {self.mail}, {self.display_name}, {self.birthday})"
)
class _UserAttribute(db.Model, ModelSerializeMixin): class _UserAttribute(db.Model, ModelSerializeMixin):
__tablename__ = "user_attribute" __tablename__ = "user_attribute"

View File

@ -33,7 +33,7 @@ class Plugin:
blueprint = None # You have to override blueprint = None # You have to override
permissions = [] # You have to override permissions = [] # You have to override
id = "dev.flaschengeist.plugin" # You have to override 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
@ -191,3 +191,14 @@ 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

@ -29,7 +29,6 @@ class AuthLDAP(AuthPlugin):
LDAP_TLS_VERSION=ssl.PROTOCOL_TLS, LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
FORCE_ATTRIBUTE_VALUE_AS_LIST=True, FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
) )
logger.warning(app.config.get("LDAP_USE_SSL"))
if "ca_cert" in config: if "ca_cert" in config:
app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"] app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"]
else: else:
@ -42,6 +41,7 @@ class AuthLDAP(AuthPlugin):
self.password_hash = config.get("password_hash", "SSHA").upper() self.password_hash = config.get("password_hash", "SSHA").upper()
self.object_classes = config.get("object_classes", ["inetOrgPerson"]) self.object_classes = config.get("object_classes", ["inetOrgPerson"])
self.user_attributes: dict = config.get("user_attributes", {}) 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) self.root_dn = config.get("root_dn", None)
@ -88,23 +88,36 @@ class AuthLDAP(AuthPlugin):
key=lambda i: i["attributes"]["uidNumber"], key=lambda i: i["attributes"]["uidNumber"],
reverse=True, reverse=True,
) )
attributes = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"] attributes["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
dn = self.dn_template.format( dn = self.dn_template.format(
firstname=user.firstname, user=user,
lastname=user.lastname,
userid=user.userid,
mail=user.mail,
display_name=user.display_name,
base_dn=self.base_dn, base_dn=self.base_dn,
) )
attributes.update({ if "default_gid" in attributes:
"sn": user.lastname, default_gid = attributes.pop("default_gid")
"givenName": user.firstname, attributes["gidNumber"] = default_gid
"uid": user.userid, if "homeDirectory" in attributes:
"userPassword": self.__hash(password), attributes["homeDirectory"] = attributes.get("homeDirectory").format(
}) firstname=user.firstname,
lastname=user.lastname,
userid=user.userid,
mail=user.mail,
display_name=user.display_name,
)
attributes.update(
{
"sn": user.lastname,
"givenName": user.firstname,
"uid": user.userid,
"userPassword": self.__hash(password),
"mail": user.mail,
}
)
if user.display_name:
attributes.update( {"displayName": user.display_name})
ldap_conn.add(dn, self.object_classes, attributes) 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
@ -146,7 +159,7 @@ 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.extend(r["jpegPhoto"][0]) avatar.binary = bytearray(r["jpegPhoto"][0])
return avatar return avatar
else: else:
raise NotFound raise NotFound
@ -177,6 +190,13 @@ class AuthLDAP(AuthPlugin):
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret) ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]}) ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
def 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
@ -241,7 +261,7 @@ class AuthLDAP(AuthPlugin):
password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode() 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}" return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}"
else: else:
return f"{{SSHA}}{base64.b64encode(sha1(password + salt) + salt)}" return f"{{SSHA}}{base64.b64encode(sha1(password.encode() + salt).digest() + salt).decode()}"
def _get_groups(self, uid): def _get_groups(self, uid):
groups = [] groups = []

View File

@ -19,7 +19,13 @@ 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(userid="admin", firstname="Admin", lastname="Admin", mail="", roles_=[role]) admin = User(
userid="admin",
firstname="Admin",
lastname="Admin",
mail="",
roles_=[role],
)
self.modify_user(admin, None, "admin") self.modify_user(admin, None, "admin")
db.session.add(admin) db.session.add(admin)
db.session.commit() db.session.commit()
@ -58,6 +64,9 @@ 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,12 +3,13 @@
# English: Debit -> from account # English: Debit -> from account
# Credit -> to account # Credit -> to account
from sqlalchemy import func from sqlalchemy import func, case, and_
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 from flaschengeist.models.user import User, _UserAttribute
from .models import Transaction from .models import Transaction
from . import permissions, BalancePlugin from . import permissions, BalancePlugin
@ -38,27 +39,114 @@ 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): def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None):
debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None) class _User(User):
credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter( _debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id])
Transaction.receiver_ != None _credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id])
)
if start:
debit = debit.filter(start <= Transaction.time)
credit = credit.filter(start <= Transaction.time)
if end:
debit = debit.filter(Transaction.time <= end)
credit = credit.filter(Transaction.time <= end)
debit = debit.group_by(Transaction._sender_id).all() @hybrid_property
credit = credit.group_by(Transaction._receiver_id).all() 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:
return sum([deb.amount for deb in self._dedit if start <= deb.time])
if end:
return sum([deb.amount for deb in self._dedit if deb.time <= end])
return self.debit
def get_credit(self, start: datetime = None, end: datetime = None):
if start and end:
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:
all[uid] = [cred, 0] for user in users:
for uid, deb in debit:
all.setdefault(uid, [0, 0]) all[user.userid] = [user.get_credit(start, end), 0]
all[uid][1] = deb all[user.userid][1] = user.get_debit(start, end)
return all
return all, count
def send(sender: User, receiver, amount: float, author: User): def send(sender: User, receiver, amount: float, author: User):
@ -114,7 +202,14 @@ def get_transaction(transaction_id) -> Transaction:
def get_transactions( def get_transactions(
user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True, descending=False user,
start=None,
end=None,
limit=None,
offset=None,
show_reversal=False,
show_cancelled=True,
descending=False,
): ):
count = None count = None
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user)) query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))

View File

@ -110,8 +110,10 @@ def limits(current_session: Session):
Returns: Returns:
JSON encoded array of userid with limit or HTTP-error JSON encoded array of userid with limit or HTTP-error
""" """
userids = None
users = userController.get_users() if "userids" in request.args:
[x for x in request.args.get("userids").split(",") if x]
users = userController.get_users(userids=userids)
if request.method == "GET": if request.method == "GET":
return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users]) return jsonify([{"userid": user.userid, "limit": user.get_attribute("balance_limit")} for user in users])
@ -311,5 +313,11 @@ 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
""" """
balances = balance_controller.get_balances() limit = request.args.get("limit", type=int)
return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()]) offset = request.args.get("offset", type=int)
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

@ -124,7 +124,14 @@ def get_templates():
return Event.query.filter(Event.is_template == True).all() return Event.query.filter(Event.is_template == True).all()
def get_events(start: Optional[datetime] = None, end=None, with_backup=False): def get_events(
start: Optional[datetime] = None,
end: Optional[datetime] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
descending: Optional[bool] = False,
with_backup=False,
):
"""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
@ -138,6 +145,14 @@ def get_events(start: Optional[datetime] = None, end=None, with_backup=False):
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)
if descending:
query = query.order_by(Event.start.desc())
else:
query = query.order_by(Event.start)
if limit is not None:
query = query.limit(limit)
if offset is not None and offset > 0:
query = query.offset(offset)
events = query.all() events = query.all()
if not with_backup: if not with_backup:
for event in events: for event in events:
@ -180,15 +195,24 @@ def create_event(event_type, start, end=None, jobs=[], is_template=None, name=No
raise BadRequest raise BadRequest
def get_job(job_slot_id, event_id): def get_job(job_id, event_id=None) -> Job:
js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none() query = Job.query.filter(Job.id == job_id)
if js is None: if event_id is not None:
query = query.filter(Job.event_id_ == event_id)
job = query.one_or_none()
if job is None:
raise NotFound raise NotFound
return js return job
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(required_services=required_services, type=job_type, start=start, end=end, comment=comment) job = Job(
required_services=required_services,
type=job_type,
start=start,
end=end,
comment=comment,
)
event.jobs.append(job) event.jobs.append(job)
update() update()
return job return job
@ -198,7 +222,10 @@ def update():
try: try:
db.session.commit() db.session.commit()
except IntegrityError: except IntegrityError:
logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True) logger.debug(
"Error, looks like a Job with that type already exists on an event",
exc_info=True,
)
raise BadRequest() raise BadRequest()
@ -209,34 +236,32 @@ def delete_job(job: Job):
db.session.commit() db.session.commit()
def assign_job(job: Job, user, value): def assign_job(job: Job, user, value, is_backup=False):
assert value > 0 assert value > 0
service = Service.query.get((job.id, user.id_)) service = Service.query.get((job.id, user.id_))
if service: if service:
service.value = value service.value = value
else: else:
service = Service(user_=user, value=value, job_=job) service = Service(user_=user, value=value, is_backup=is_backup, job_=job)
db.session.add(service) db.session.add(service)
db.session.commit() db.session.commit()
def unassign_job(job: Job = None, user=None, service=None, notify=False): def unassign_job(job: Job = None, user=None, service=None, notify=False):
if service is None: if service is None:
assert(job is not None and user is not None) assert job is not None and user is not None
service = Service.query.get((job.id, user.id_)) service = Service.query.get((job.id, user.id_))
else: else:
user = service.user_ user = service.user_
if not service: if not service:
raise BadRequest raise BadRequest
event_id = service.job_.event_id_ event_id = service.job_.event_id_
db.session.delete(service) db.session.delete(service)
db.session.commit() db.session.commit()
if notify: if notify:
EventPlugin.plugin.notify( EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id})
user, "Your assignmet was cancelled", {"event_id": event_id}
)
@scheduled @scheduled
@ -249,7 +274,9 @@ 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_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_} service.user_,
"Your backup assignment was cancelled.",
{"event_id": service.job_.event_id_},
) )
logger.debug(f"Service is outdated or full, removing. {service.serialize()}") logger.debug(f"Service is outdated or full, removing. {service.serialize()}")
db.session.delete(service) db.session.delete(service)
@ -257,6 +284,8 @@ 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_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_} service.user_,
"Your backup assignment was accepted.",
{"event_id": service.job_.event_id_},
) )
db.session.commit() db.session.commit()

View File

@ -39,7 +39,13 @@ 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", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True) _job_id = db.Column(
"job_id",
Serial,
db.ForeignKey(f"{_table_prefix_}job.id"),
nullable=False,
primary_key=True,
)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True) _user_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")
@ -59,6 +65,7 @@ class Job(db.Model, ModelSerializeMixin):
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())
services: list[Service] = db.relationship("Service", back_populates="job_") services: list[Service] = db.relationship("Service", back_populates="job_")
required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False) required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False)
@ -83,11 +90,17 @@ 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", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]" "Job",
back_populates="event_",
cascade="all,delete,delete-orphan",
order_by="[Job.start, Job.end]",
) )
# Protected for internal use # Protected for internal use
_type_id = db.Column( _type_id = db.Column(
"type_id", Serial, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False "type_id",
Serial,
db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"),
nullable=False,
) )

View File

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

View File

@ -2,12 +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, Unauthorized from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.controller import userController 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 from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.utils.HTTP import no_content from flaschengeist.utils.HTTP import no_content
from . import models from . import models
@ -709,7 +710,7 @@ def get_priclist_setting(userid, current_session):
return no_content() return no_content()
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "GET", "DELETE"]) @PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["POST", "DELETE"])
@login_required(permission=permissions.EDIT) @login_required(permission=permissions.EDIT)
def set_picture(identifier, current_session): def set_picture(identifier, current_session):
"""Get, Create, Delete Drink Picture """Get, Create, Delete Drink Picture
@ -731,25 +732,25 @@ def set_picture(identifier, current_session):
file = request.files.get("file") file = request.files.get("file")
if file: if file:
picture = models._Picture() return jsonify(pricelist_controller.save_drink_picture(identifier, file))
picture.mimetype = file.content_type
picture.binary = bytearray(file.stream.read())
return jsonify(pricelist_controller.save_drink_picture(identifier, picture))
else: else:
raise BadRequest raise BadRequest
@PriceListPlugin.blueprint.route("/picture/<identifier>", methods=["GET"]) @PriceListPlugin.blueprint.route("/drinks/<int:identifier>/picture", methods=["GET"])
#@headers({"Cache-Control": "private, must-revalidate"})
def _get_picture(identifier): def _get_picture(identifier):
"""Get Picture """Get Picture
Args: Args:
identifier: Identifier of Picture identifier: Identifier of Drink
Returns: Returns:
Picture or HTTP-error Picture or HTTP-error
""" """
if request.method == "GET": drink = pricelist_controller.get_drink(identifier)
size = request.args.get("size") if drink.has_image:
response = pricelist_controller.get_drink_picture(identifier, size) if request.args.get("thumbnail"):
return response.make_conditional(request) return send_thumbnail(image=drink.image_)
return send_image(image=drink.image_)
raise NotFound

View File

@ -1,20 +1,21 @@
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 from flaschengeist.models import ModelSerializeMixin, Serial
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", db.Integer, db.ForeignKey("drink.id")), db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
db.Column("tag_id", db.Integer, db.ForeignKey("drink_tag.id")), db.Column("tag_id", Serial, 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", db.Integer, db.ForeignKey("drink.id")), db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")), db.Column("type_id", Serial, db.ForeignKey("drink_type.id")),
) )
@ -24,7 +25,7 @@ class Tag(db.Model, ModelSerializeMixin):
""" """
__tablename__ = "drink_tag" __tablename__ = "drink_tag"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, 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) color: str = db.Column(db.String(7), nullable=False)
@ -35,7 +36,7 @@ class DrinkType(db.Model, ModelSerializeMixin):
""" """
__tablename__ = "drink_type" __tablename__ = "drink_type"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, 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)
@ -45,9 +46,9 @@ class DrinkPrice(db.Model, ModelSerializeMixin):
""" """
__tablename__ = "drink_price" __tablename__ = "drink_price"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, 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", db.Integer, db.ForeignKey("drink_price_volume.id")) volume_id_ = db.Column("volume_id", Serial, db.ForeignKey("drink_price_volume.id"))
volume: "DrinkPriceVolume" = None volume: "DrinkPriceVolume" = None
_volume: "DrinkPriceVolume" = db.relationship("DrinkPriceVolume", back_populates="_prices", join_depth=1) _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)
@ -62,8 +63,8 @@ class ExtraIngredient(db.Model, ModelSerializeMixin):
ExtraIngredient ExtraIngredient
""" """
__tablename__ = "extra_ingredient" __tablename__ = "drink_extra_ingredient"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, 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))
@ -74,9 +75,9 @@ class DrinkIngredient(db.Model, ModelSerializeMixin):
""" """
__tablename__ = "drink_ingredient" __tablename__ = "drink_ingredient"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id")) ingredient_id: int = db.Column(Serial, db.ForeignKey("drink.id"))
cost_per_volume: float cost_per_volume: float
name: str name: str
_drink_ingredient: Drink = db.relationship("Drink") _drink_ingredient: Drink = db.relationship("Drink")
@ -95,14 +96,14 @@ class Ingredient(db.Model, ModelSerializeMixin):
Ingredient Associationtable Ingredient Associationtable
""" """
__tablename__ = "ingredient_association" __tablename__ = "drink_ingredient_association"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
volume_id = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id")) volume_id = db.Column(Serial, db.ForeignKey("drink_price_volume.id"))
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient) drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient, cascade="all,delete")
extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient) extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient)
_drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id")) _drink_ingredient_id = db.Column(Serial, db.ForeignKey("drink_ingredient.id"))
_extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id")) _extra_ingredient_id = db.Column(Serial, db.ForeignKey("drink_extra_ingredient.id"))
class MinPrices(ModelSerializeMixin): class MinPrices(ModelSerializeMixin):
@ -120,8 +121,8 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin):
""" """
__tablename__ = "drink_price_volume" __tablename__ = "drink_price_volume"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, primary_key=True)
drink_id = db.Column(db.Integer, db.ForeignKey("drink.id")) drink_id = db.Column(Serial, db.ForeignKey("drink.id"))
drink: "Drink" = None drink: "Drink" = None
_drink: "Drink" = db.relationship("Drink", back_populates="_volumes") _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))
@ -131,10 +132,14 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin):
_prices: list[DrinkPrice] = db.relationship( _prices: list[DrinkPrice] = db.relationship(
DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan" DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan"
) )
ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id) ingredients: list[Ingredient] = db.relationship(
"Ingredient",
foreign_keys=Ingredient.volume_id,
cascade="all,delete,delete-orphan",
)
def __repr__(self): def __repr__(self):
return f"DrinkPriceVolume({self.id},{self.drink_id},{self.prices})" return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})"
class Drink(db.Model, ModelSerializeMixin): class Drink(db.Model, ModelSerializeMixin):
@ -143,18 +148,21 @@ class Drink(db.Model, ModelSerializeMixin):
""" """
__tablename__ = "drink" __tablename__ = "drink"
id: int = db.Column("id", db.Integer, primary_key=True) id: int = db.Column("id", Serial, 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
uuid: str = db.Column(db.String(36))
receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4)) receipt: Optional[list[str]] = db.Column(db.PickleType(protocol=4))
_type_id = db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")) _type_id = db.Column("type_id", Serial, db.ForeignKey("drink_type.id"))
_image_id = db.Column("image_id", Serial, db.ForeignKey("image.id"))
image_: Image = db.relationship("Image", cascade="all, delete", foreign_keys=[_image_id])
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])
@ -166,9 +174,6 @@ class Drink(db.Model, ModelSerializeMixin):
def __repr__(self): def __repr__(self):
return f"Drink({self.id},{self.name},{self.volumes})" 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

@ -1,16 +1,24 @@
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.picture import save_picture, get_picture, delete_picture
from flaschengeist.utils.decorators import extract_session from flaschengeist.utils.decorators import extract_session
from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient from .models import (
Drink,
DrinkPrice,
Ingredient,
Tag,
DrinkType,
DrinkPriceVolume,
DrinkIngredient,
ExtraIngredient,
)
from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK
import flaschengeist.controller.imageController as image_controller
def update(): def update():
db.session.commit() db.session.commit()
@ -132,7 +140,14 @@ def _create_public_drink(drink):
def get_drinks( def get_drinks(
name=None, public=False, limit=None, offset=None, search_name=None, search_key=None, ingredient=False, receipt=None name=None,
public=False,
limit=None,
offset=None,
search_name=None,
search_key=None,
ingredient=False,
receipt=None,
): ):
count = None count = None
if name: if name:
@ -178,7 +193,13 @@ def get_drinks(
def get_pricelist( def get_pricelist(
public=False, limit=None, offset=None, search_name=None, search_key=None, sortBy=None, descending=False public=False,
limit=None,
offset=None,
search_name=None,
search_key=None,
sortBy=None,
descending=False,
): ):
count = None count = None
query = DrinkPrice.query query = DrinkPrice.query
@ -302,7 +323,7 @@ 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): if hasattr(drink, key) and key != "has_image":
setattr(drink, key, value if value != "" else None) setattr(drink, key, value if value != "" else None)
if drink_type: if drink_type:
@ -333,9 +354,6 @@ def set_volumes(volumes):
def delete_drink(identifier): def delete_drink(identifier):
drink = get_drink(identifier) drink = get_drink(identifier)
if drink.uuid:
path = config["pricelist"]["path"]
delete_picture(f"{path}/{drink.uuid}")
db.session.delete(drink) db.session.delete(drink)
db.session.commit() db.session.commit()
@ -360,16 +378,9 @@ 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")
vol_id = values.pop("id", None) 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 and session.user_.has_permission(EDIT_PRICE):
set_prices(prices, volume) set_prices(prices, volume)
@ -419,16 +430,9 @@ def set_price(data):
allowed_keys.append("description") allowed_keys.append("description")
logger.debug(f"allowed_key {allowed_keys}") 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}
price_id = values.pop("id", -1) 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,16 +450,9 @@ def set_drink_ingredient(data):
values.pop("cost_per_volume") values.pop("cost_per_volume")
if "name" in values: if "name" in values:
values.pop("name") values.pop("name")
ingredient_id = values.pop("id", -1) values.pop("id", -1)
if ingredient_id < 0: 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
@ -470,14 +467,9 @@ 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")
ingredient_id = data.pop("id", -1) 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:
@ -533,34 +525,16 @@ def delete_extra_ingredient(identifier):
def save_drink_picture(identifier, file): def save_drink_picture(identifier, file):
drink = get_drink(identifier) drink = delete_drink_picture(identifier)
old_uuid = None drink.image_ = image_controller.upload_image(file)
if drink.uuid:
old_uuid = drink.uuid
drink.uuid = str(uuid4())
db.session.commit() db.session.commit()
path = config["pricelist"]["path"]
save_picture(file, f"{path}/{drink.uuid}")
if old_uuid:
delete_picture(f"{path}/{old_uuid}")
return drink return drink
def get_drink_picture(identifier, size=None):
path = config["pricelist"]["path"]
drink = None
if isinstance(identifier, int):
drink = get_drink(identifier)
if isinstance(identifier, str):
drink = Drink.query.filter(Drink.uuid == identifier).one_or_none()
if drink:
return get_picture(f"{path}/{drink.uuid}", size)
raise FileNotFoundError
def delete_drink_picture(identifier): def delete_drink_picture(identifier):
drink = get_drink(identifier) drink = get_drink(identifier)
if drink.uuid: if drink.image_:
delete_picture(f"{config['pricelist']['path']}/{drink.uuid}") db.session.delete(drink.image_)
drink.uuid = None drink.image_ = None
db.session.commit() db.session.commit()
return drink

View File

@ -69,7 +69,10 @@ def list_users(current_session):
JSON encoded array of `flaschengeist.models.user.User` or HTTP error JSON encoded array of `flaschengeist.models.user.User` or HTTP error
""" """
logger.debug("Retrieve list of all users") logger.debug("Retrieve list of all users")
users = userController.get_users() userids = None
if "userids" in request.args:
userids = [x for x in request.args.get("userids").split(",") if x]
users = userController.get_users(userids=userids)
return jsonify(users) return jsonify(users)
@ -144,6 +147,16 @@ 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):

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

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

View File

@ -167,6 +167,28 @@ def export(arguments):
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()
@ -192,5 +214,8 @@ if __name__ == "__main__":
) )
parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*") 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,7 +20,16 @@ class DocsCommand(Command):
def run(self): def run(self):
"""Run command.""" """Run command."""
command = ["python", "-m", "pdoc", "--skip-errors", "--html", "--output-dir", self.output, "flaschengeist"] command = [
"python",
"-m",
"pdoc",
"--skip-errors",
"--html",
"--output-dir",
self.output,
"flaschengeist",
]
self.announce( self.announce(
"Running command: %s" % str(command), "Running command: %s" % str(command),
) )
@ -33,18 +42,18 @@ setup(
scripts=["run_flaschengeist"], scripts=["run_flaschengeist"],
python_requires=">=3.7", python_requires=">=3.7",
install_requires=[ install_requires=[
"Flask >= 1.1", "Flask >= 2.0",
"toml", "toml",
"sqlalchemy>=1.4", "sqlalchemy>=1.4.26",
"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"], "ldap": ["flask_ldapconn", "ldap3"],
"argon": ["argon2-cffi"], "argon": ["argon2-cffi"],
"pricelist": ["pillow"],
"test": ["pytest", "coverage"], "test": ["pytest", "coverage"],
}, },
entry_points={ entry_points={
@ -59,7 +68,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]", "pricelist = flaschengeist.plugins.pricelist:PriceListPlugin",
], ],
}, },
cmdclass={ cmdclass={

View File

@ -22,7 +22,13 @@ 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({"TESTING": True, "DATABASE": {"file_path": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}}) app = create_app(
{
"TESTING": True,
"DATABASE": {"file_path": f"/{db_path}"},
"LOGGING": {"level": "DEBUG"},
}
)
with app.app_context(): with app.app_context():
install_all() install_all()
engine = database.db.engine engine = database.db.engine