Compare commits
90 Commits
ce074bb51a
...
e626239d84
Author | SHA1 | Date |
---|---|---|
|
e626239d84 | |
|
aa64c769ef | |
|
1c091311de | |
|
1609d8ae29 | |
|
41f625aabc | |
|
c3468eea03 | |
|
2634181d5e | |
|
25b174b1c2 | |
|
b4086108e4 | |
|
eb04d305ab | |
|
471258c886 | |
|
7cac708309 | |
|
9b935541b0 | |
|
d17b0a7cab | |
|
ff03325e2b | |
|
04d5b1e83a | |
|
51a3a8dfc8 | |
|
d75574e078 | |
|
0be31d0bfe | |
|
26d63b7c7d | |
|
f7f27311db | |
|
00c9da4ff2 | |
|
795475fe15 | |
|
d00c603697 | |
|
48933cdf5f | |
|
7cb31bf60e | |
|
05dc158719 | |
|
6535aeab2e | |
|
92183a4235 | |
|
c6c41adb02 | |
|
f1d973b446 | |
|
45ed9219a4 | |
|
0ef9d18ace | |
|
ae1bf6c54b | |
|
f205291d6d | |
|
6a9db1b36a | |
|
e3d0014e62 | |
|
a43441e0c5 | |
|
a6fe921920 | |
|
42e304cf5f | |
|
55278c8413 | |
|
57a03a80cc | |
|
dba60fdab8 | |
|
1bb7bafa2a | |
|
e4b937991b | |
|
526433afba | |
|
1b371763ee | |
|
8fb74358e7 | |
|
26a00ed6a6 | |
|
974af80a9b | |
|
ff13eefb45 | |
|
5ef603ee50 | |
|
80f06e483b | |
|
f80ad5c420 | |
|
4e1799e297 | |
|
f7e07fdade | |
|
3d833fb6af | |
|
2dabd1dd34 | |
|
cadde543f2 | |
|
4e46ea1ca3 | |
|
0d1a39f217 | |
|
7129469835 | |
|
e7b978ae3c | |
|
ff1a0544f8 | |
|
3fc04c4143 | |
|
7928c16c07 | |
|
7b5f854d51 | |
|
8696699ecb | |
|
776332d5fe | |
|
96e4e73f4b | |
|
1e5304fe1e | |
|
3da2ed53d5 | |
|
f5624e9a7d | |
|
2d31cda665 | |
|
2da0bb1683 | |
|
0630b5183d | |
|
32ad4471c6 | |
|
1d36c3ef6c | |
|
2d45c0dab9 | |
|
62948cd591 | |
|
5c688df392 | |
|
15c7a56d56 | |
|
bcf1941a81 | |
|
b2d8431697 | |
|
3a4e90f50e | |
|
faf5b0b8d0 | |
|
e8c9c6e66c | |
|
2ae8bc7e0c | |
|
6fce88c120 | |
|
6dbb135621 |
|
@ -122,6 +122,8 @@ dmypy.json
|
|||
.vscode/
|
||||
*.log
|
||||
|
||||
data/
|
||||
|
||||
# config
|
||||
flaschengeist/flaschengeist.toml
|
||||
|
||||
|
|
|
@ -52,8 +52,11 @@ def __load_plugins(app):
|
|||
app.register_blueprint(plugin.blueprint)
|
||||
except:
|
||||
logger.error(
|
||||
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.", exc_info=True
|
||||
f"Plugin {entry_point.name} was enabled, but could not be loaded due to an error.",
|
||||
exc_info=True,
|
||||
)
|
||||
del plugin
|
||||
continue
|
||||
if isinstance(plugin, AuthPlugin):
|
||||
logger.debug(f"Found authentication plugin: {entry_point.name}")
|
||||
if entry_point.name == config["FLASCHENGEIST"]["auth"]:
|
||||
|
@ -65,6 +68,7 @@ def __load_plugins(app):
|
|||
app.config["FG_PLUGINS"][entry_point.name] = plugin
|
||||
if "FG_AUTH_BACKEND" not in app.config:
|
||||
logger.error("No authentication plugin configured or authentication plugin not found")
|
||||
raise RuntimeError("No authentication plugin configured or authentication plugin not found")
|
||||
|
||||
|
||||
def install_all():
|
||||
|
|
|
@ -9,7 +9,7 @@ from flaschengeist import _module_path, logger
|
|||
|
||||
|
||||
# Default config:
|
||||
config = {"DATABASE": {"port": 3306}}
|
||||
config = {"DATABASE": {"engine": "mysql", "port": 3306}}
|
||||
|
||||
|
||||
def update_dict(d, u):
|
||||
|
@ -41,17 +41,21 @@ def read_configuration(test_config):
|
|||
update_dict(config, test_config)
|
||||
|
||||
|
||||
def configure_app(app, test_config=None):
|
||||
def configure_logger():
|
||||
global config
|
||||
read_configuration(test_config)
|
||||
|
||||
# Always enable this builtin plugins!
|
||||
update_dict(config, {"auth": {"enabled": True}, "roles": {"enabled": True}, "users": {"enabled": True}})
|
||||
|
||||
# Read default config
|
||||
logger_config = toml.load(_module_path / "logging.toml")
|
||||
|
||||
if "LOGGING" in config:
|
||||
# Override with user config
|
||||
update_dict(logger_config, config.get("LOGGING"))
|
||||
# Check for shortcuts
|
||||
if "level" in config["LOGGING"]:
|
||||
logger_config["loggers"]["flaschengeist"] = {"level": config["LOGGING"]["level"]}
|
||||
logger_config["handlers"]["console"]["level"] = config["LOGGING"]["level"]
|
||||
logger_config["handlers"]["file"]["level"] = config["LOGGING"]["level"]
|
||||
if not config["LOGGING"].get("console", True):
|
||||
logger_config["handlers"]["console"]["level"] = "CRITICAL"
|
||||
if "file" in config["LOGGING"]:
|
||||
logger_config["root"]["handlers"].append("file")
|
||||
logger_config["handlers"]["file"]["filename"] = config["LOGGING"]["file"]
|
||||
|
@ -59,23 +63,58 @@ def configure_app(app, test_config=None):
|
|||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
logging.config.dictConfig(logger_config)
|
||||
|
||||
|
||||
def configure_app(app, test_config=None):
|
||||
global config
|
||||
read_configuration(test_config)
|
||||
|
||||
configure_logger()
|
||||
|
||||
# Always enable this builtin plugins!
|
||||
update_dict(
|
||||
config,
|
||||
{
|
||||
"auth": {"enabled": True},
|
||||
"roles": {"enabled": True},
|
||||
"users": {"enabled": True},
|
||||
},
|
||||
)
|
||||
|
||||
if "secret_key" not in config["FLASCHENGEIST"]:
|
||||
logger.warning("No secret key was configured, please configure one for production systems!")
|
||||
app.config["SECRET_KEY"] = "0a657b97ef546da90b2db91862ad4e29"
|
||||
else:
|
||||
app.config["SECRET_KEY"] = config["FLASCHENGEIST"]["secret_key"]
|
||||
|
||||
if test_config is None:
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = "mysql{driver}://{user}:{passwd}@{host}:{port}/{database}".format(
|
||||
driver="+pymysql" if os.name == "nt" else "",
|
||||
if test_config is not None:
|
||||
config["DATABASE"]["engine"] = "sqlite"
|
||||
|
||||
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"],
|
||||
passwd=config["DATABASE"]["password"],
|
||||
password=config["DATABASE"]["password"],
|
||||
host=config["DATABASE"]["host"],
|
||||
database=config["DATABASE"]["database"],
|
||||
port=config["DATABASE"]["port"],
|
||||
)
|
||||
else:
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite+pysqlite://{config['DATABASE']['file_path']}"
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = f"{engine}://{host}/{config['DATABASE']['database']}{options}"
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
if "root" in config["FLASCHENGEIST"]:
|
||||
|
|
|
@ -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
|
|
@ -2,7 +2,7 @@ from sqlalchemy.exc import IntegrityError
|
|||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from flaschengeist.models.user import Role, Permission
|
||||
from flaschengeist.database import db
|
||||
from flaschengeist.database import db, case_sensitive
|
||||
from flaschengeist import logger
|
||||
from flaschengeist.utils.hook import Hook
|
||||
|
||||
|
@ -36,8 +36,8 @@ def update_role(role, new_name):
|
|||
except IntegrityError:
|
||||
logger.debug("IntegrityError: Role might still be in use", exc_info=True)
|
||||
raise BadRequest("Role still in use")
|
||||
elif role.name != new_name:
|
||||
if db.session.query(db.exists().where(Role.name == new_name)).scalar():
|
||||
else:
|
||||
if role.name == new_name or db.session.query(db.exists().where(Role.name == case_sensitive(new_name))).scalar():
|
||||
raise BadRequest("Name already used")
|
||||
role.name = new_name
|
||||
db.session.commit()
|
||||
|
|
|
@ -175,8 +175,8 @@ def register(data):
|
|||
allowed_keys = User().serialize().keys()
|
||||
values = {key: value for key, value in data.items() if key in allowed_keys}
|
||||
roles = values.pop("roles", [])
|
||||
if "birthday" in values:
|
||||
values["birthday"] = from_iso_format(values["birthday"]).date()
|
||||
if "birthday" in data:
|
||||
values["birthday"] = from_iso_format(data["birthday"]).date()
|
||||
user = User(**values)
|
||||
set_roles(user, roles)
|
||||
|
||||
|
@ -195,6 +195,8 @@ def register(data):
|
|||
)
|
||||
messageController.send_message(messageController.Message(user, text, subject))
|
||||
|
||||
find_user(user.userid)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
@ -207,6 +209,11 @@ def save_avatar(user, avatar):
|
|||
db.session.commit()
|
||||
|
||||
|
||||
def delete_avatar(user):
|
||||
current_app.config["FG_AUTH_BACKEND"].delete_avatar(user)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def persist(user=None):
|
||||
if user:
|
||||
db.session.add(user)
|
||||
|
|
|
@ -1,3 +1,11 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
|
||||
def case_sensitive(s):
|
||||
if db.session.bind.dialect.name == "mysql":
|
||||
from sqlalchemy import func
|
||||
|
||||
return func.binary(s)
|
||||
return s
|
||||
|
|
|
@ -7,38 +7,52 @@ auth = "auth_plain"
|
|||
# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn
|
||||
#proxy = false
|
||||
# Set root path, prefixes all routes
|
||||
#root = /api
|
||||
root = "/api"
|
||||
# Set secret key
|
||||
secret_key = "V3ryS3cr3t"
|
||||
# Domain used by frontend
|
||||
#domain = "flaschengeist.local"
|
||||
|
||||
[LOGGING]
|
||||
# Uncomment to enable logging to a file
|
||||
#file = "/tmp/flaschengeist-debug.log"
|
||||
# You can override all settings from the logging.toml here
|
||||
# E.g. override the formatters etc
|
||||
#
|
||||
# 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]
|
||||
# user = "user"
|
||||
# host = "127.0.0.1"
|
||||
# password = "password"
|
||||
# database = "database"
|
||||
# engine = "mysql" (default)
|
||||
|
||||
[FILES]
|
||||
# Path for file / image uploads
|
||||
data_path = "./data"
|
||||
# Thumbnail size
|
||||
thumbnail_size = [192, 192]
|
||||
# Accepted mimetypes
|
||||
allowed_mimetypes = [
|
||||
"image/avif",
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp"
|
||||
]
|
||||
|
||||
[auth_plain]
|
||||
enabled = true
|
||||
|
||||
#[auth_ldap]
|
||||
# enabled = true
|
||||
# host =
|
||||
# port =
|
||||
# bind_dn =
|
||||
# base_dn =
|
||||
# secret =
|
||||
# use_ssl =
|
||||
# admin_dn =
|
||||
# admin_dn =
|
||||
# default_gid =
|
||||
[auth_ldap]
|
||||
# Full documentation https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/plugins_auth_ldap
|
||||
enabled = false
|
||||
# host = "localhost"
|
||||
# port = 389
|
||||
# base_dn = "dc=example,dc=com"
|
||||
# root_dn = "cn=Manager,dc=example,dc=com"
|
||||
# root_secret = "SuperS3cret"
|
||||
# Uncomment to use secured LDAP (ldaps)
|
||||
# use_ssl = true
|
||||
|
||||
[MESSAGES]
|
||||
welcome_subject = "Welcome to Flaschengeist {name}"
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
# This is the default flaschengeist logger configuration
|
||||
# If you want to customize it, use the flaschengeist.toml
|
||||
|
||||
version = 1
|
||||
disable_existing_loggers = false
|
||||
|
||||
[formatters]
|
||||
[formatters.simple]
|
||||
format = "%(asctime)s - %(name)s - %(message)s"
|
||||
format = "%(asctime)s - %(levelname)s - %(message)s"
|
||||
[formatters.extended]
|
||||
format = "%(asctime)s — %(filename)s - %(funcName)s - %(lineno)d - %(threadName)s - %(name)s — %(levelname)s — %(message)s"
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@ import sys
|
|||
import datetime
|
||||
|
||||
from sqlalchemy import BigInteger
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.dialects import mysql, sqlite
|
||||
from sqlalchemy.types import DateTime, TypeDecorator
|
||||
|
||||
|
||||
|
@ -44,7 +44,8 @@ class ModelSerializeMixin:
|
|||
class Serial(TypeDecorator):
|
||||
"""Same as MariaDB Serial used for IDs"""
|
||||
|
||||
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql")
|
||||
cache_ok = True
|
||||
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql").with_variant(sqlite.INTEGER, "sqlite")
|
||||
|
||||
|
||||
class UtcDateTime(TypeDecorator):
|
||||
|
@ -60,6 +61,7 @@ class UtcDateTime(TypeDecorator):
|
|||
aware value, even with SQLite or MySQL.
|
||||
"""
|
||||
|
||||
cache_ok = True
|
||||
impl = DateTime(timezone=True)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
@ -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()
|
|
@ -56,7 +56,7 @@ class User(db.Model, ModelSerializeMixin):
|
|||
display_name: str = db.Column(db.String(30))
|
||||
firstname: str = db.Column(db.String(50), nullable=False)
|
||||
lastname: str = db.Column(db.String(50), nullable=False)
|
||||
mail: str = db.Column(db.String(60), nullable=False)
|
||||
mail: str = db.Column(db.String(60))
|
||||
birthday: Optional[date] = db.Column(db.Date)
|
||||
roles: list[str] = []
|
||||
permissions: Optional[list[str]] = None
|
||||
|
@ -67,7 +67,9 @@ class User(db.Model, ModelSerializeMixin):
|
|||
sessions_ = db.relationship("Session", back_populates="user_")
|
||||
|
||||
_attributes = db.relationship(
|
||||
"_UserAttribute", collection_class=attribute_mapped_collection("name"), cascade="all, delete"
|
||||
"_UserAttribute",
|
||||
collection_class=attribute_mapped_collection("name"),
|
||||
cascade="all, delete",
|
||||
)
|
||||
|
||||
@property
|
||||
|
@ -92,6 +94,10 @@ class User(db.Model, ModelSerializeMixin):
|
|||
return self._attributes[name].value
|
||||
return default
|
||||
|
||||
def delete_attribute(self, name):
|
||||
if name in self._attributes:
|
||||
self._attributes.pop(name)
|
||||
|
||||
def get_permissions(self):
|
||||
return ["user"] + [permission.name for role in self.roles_ for permission in role.permissions]
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ class Plugin:
|
|||
|
||||
blueprint = None # You have to override
|
||||
permissions = [] # You have to override
|
||||
id = "dev.flaschengeist.plugin" # You have to override
|
||||
name = "plugin" # You have to override
|
||||
models = None # You have to override
|
||||
|
||||
|
@ -94,7 +95,7 @@ class Plugin:
|
|||
db.session.commit()
|
||||
|
||||
def notify(self, user, text: str, data=None):
|
||||
n = Notification(text=text, data=data, plugin=self.name, user_=user)
|
||||
n = Notification(text=text, data=data, plugin=self.id, user_=user)
|
||||
db.session.add(n)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -190,3 +191,14 @@ class AuthPlugin(Plugin):
|
|||
MethodNotAllowed: If not supported by Backend
|
||||
"""
|
||||
raise MethodNotAllowed
|
||||
|
||||
def delete_avatar(self, user):
|
||||
"""Delete the avatar for given user (if supported by auth backend)
|
||||
|
||||
Args:
|
||||
user: Uset to delete the avatar for
|
||||
|
||||
Raises:
|
||||
MethodNotAllowed: If not supported by Backend
|
||||
"""
|
||||
raise MethodNotAllowed
|
||||
|
|
|
@ -1,56 +1,61 @@
|
|||
"""LDAP Authentication Provider Plugin"""
|
||||
import io
|
||||
import os
|
||||
import ssl
|
||||
from typing import Optional
|
||||
from flask_ldapconn import LDAPConn
|
||||
from flask import current_app as app
|
||||
from ldap3.utils.hashed import hashed
|
||||
from ldap3.core.exceptions import LDAPPasswordIsMandatoryError, LDAPBindError
|
||||
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE, HASHED_SALTED_MD5
|
||||
from ldap3 import SUBTREE, MODIFY_REPLACE, MODIFY_ADD, MODIFY_DELETE
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
from flaschengeist import logger
|
||||
from flaschengeist.plugins import AuthPlugin, after_role_updated
|
||||
from flaschengeist.plugins import AuthPlugin, before_role_updated
|
||||
from flaschengeist.models.user import User, Role, _Avatar
|
||||
import flaschengeist.controller.userController as userController
|
||||
|
||||
|
||||
class AuthLDAP(AuthPlugin):
|
||||
def __init__(self, cfg):
|
||||
def __init__(self, config):
|
||||
super().__init__()
|
||||
config = {"port": 389, "use_ssl": False}
|
||||
config.update(cfg)
|
||||
app.config.update(
|
||||
LDAP_SERVER=config["host"],
|
||||
LDAP_PORT=config["port"],
|
||||
LDAP_BINDDN=config["bind_dn"],
|
||||
LDAP_SERVER=config.get("host", "localhost"),
|
||||
LDAP_PORT=config.get("port", 389),
|
||||
LDAP_BINDDN=config.get("bind_dn", None),
|
||||
LDAP_SECRET=config.get("secret", None),
|
||||
LDAP_USE_SSL=config.get("use_ssl", False),
|
||||
# That's not TLS, its dirty StartTLS on unencrypted LDAP
|
||||
LDAP_USE_TLS=False,
|
||||
LDAP_USE_SSL=config["use_ssl"],
|
||||
LDAP_TLS_VERSION=ssl.PROTOCOL_TLSv1_2,
|
||||
LDAP_REQUIRE_CERT=ssl.CERT_NONE,
|
||||
LDAP_TLS_VERSION=ssl.PROTOCOL_TLS,
|
||||
FORCE_ATTRIBUTE_VALUE_AS_LIST=True,
|
||||
)
|
||||
if "secret" in config:
|
||||
app.config["LDAP_SECRET"] = config["secret"]
|
||||
if "ca_cert" in config:
|
||||
app.config["LDAP_CA_CERTS_FILE"] = config["ca_cert"]
|
||||
else:
|
||||
# Default is CERT_REQUIRED
|
||||
app.config["LDAP_REQUIRE_CERT"] = ssl.CERT_OPTIONAL
|
||||
self.ldap = LDAPConn(app)
|
||||
self.dn = config["base_dn"]
|
||||
self.default_gid = config["default_gid"]
|
||||
self.base_dn = config["base_dn"]
|
||||
self.search_dn = config.get("search_dn", "ou=people,{base_dn}").format(base_dn=self.base_dn)
|
||||
self.group_dn = config.get("group_dn", "ou=group,{base_dn}").format(base_dn=self.base_dn)
|
||||
self.password_hash = config.get("password_hash", "SSHA").upper()
|
||||
self.object_classes = config.get("object_classes", ["inetOrgPerson"])
|
||||
self.user_attributes: dict = config.get("user_attributes", {})
|
||||
self.dn_template = config.get("dn_template")
|
||||
|
||||
# TODO: might not be set if modify is called
|
||||
if "admin_dn" in config:
|
||||
self.admin_dn = config["admin_dn"]
|
||||
self.admin_secret = config["admin_secret"]
|
||||
else:
|
||||
self.admin_dn = None
|
||||
self.root_dn = config.get("root_dn", None)
|
||||
self.root_secret = config.get("root_secret", None)
|
||||
|
||||
@after_role_updated
|
||||
@before_role_updated
|
||||
def _role_updated(role, new_name):
|
||||
logger.debug(f"LDAP: before_role_updated called with ({role}, {new_name})")
|
||||
self.__modify_role(role, new_name)
|
||||
|
||||
def login(self, user, password):
|
||||
if not user:
|
||||
return False
|
||||
return self.ldap.authenticate(user.userid, password, "uid", self.dn)
|
||||
return self.ldap.authenticate(user.userid, password, "uid", self.base_dn)
|
||||
|
||||
def find_user(self, userid, mail=None):
|
||||
attr = self.__find(userid, mail)
|
||||
|
@ -64,43 +69,55 @@ class AuthLDAP(AuthPlugin):
|
|||
self.__update(user, attr)
|
||||
|
||||
def create_user(self, user, password):
|
||||
if self.admin_dn is None:
|
||||
logger.error("admin_dn missing in ldap config!")
|
||||
if self.root_dn is None:
|
||||
logger.error("root_dn missing in ldap config!")
|
||||
raise InternalServerError
|
||||
|
||||
try:
|
||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
||||
self.ldap.connection.search(
|
||||
"ou=user,{}".format(self.dn),
|
||||
"(uidNumber=*)",
|
||||
SUBTREE,
|
||||
attributes=["uidNumber"],
|
||||
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
attributes = self.user_attributes.copy()
|
||||
if "uidNumber" in attributes:
|
||||
self.ldap.connection.search(
|
||||
self.search_dn,
|
||||
"(uidNumber=*)",
|
||||
SUBTREE,
|
||||
attributes=["uidNumber"],
|
||||
)
|
||||
resp = sorted(
|
||||
self.ldap.response(),
|
||||
key=lambda i: i["attributes"]["uidNumber"],
|
||||
reverse=True,
|
||||
)
|
||||
attributes["uidNumber"] = resp[0]["attributes"]["uidNumber"] + 1 if resp else attributes["uidNumber"]
|
||||
dn = self.dn_template.format(
|
||||
user=user,
|
||||
base_dn=self.base_dn,
|
||||
)
|
||||
uid_number = (
|
||||
sorted(self.ldap.response(), key=lambda i: i["attributes"]["uidNumber"], reverse=True,)[0][
|
||||
"attributes"
|
||||
]["uidNumber"]
|
||||
+ 1
|
||||
if "default_gid" in attributes:
|
||||
default_gid = attributes.pop("default_gid")
|
||||
attributes["gidNumber"] = default_gid
|
||||
if "homeDirectory" in attributes:
|
||||
attributes["homeDirectory"] = attributes.get("homeDirectory").format(
|
||||
firstname=user.firstname,
|
||||
lastname=user.lastname,
|
||||
userid=user.userid,
|
||||
mail=user.mail,
|
||||
display_name=user.display_name,
|
||||
)
|
||||
attributes.update(
|
||||
{
|
||||
"sn": user.lastname,
|
||||
"givenName": user.firstname,
|
||||
"uid": user.userid,
|
||||
"userPassword": self.__hash(password),
|
||||
"mail": user.mail,
|
||||
}
|
||||
)
|
||||
dn = f"cn={user.firstname} {user.lastname},ou=user,{self.dn}"
|
||||
object_class = [
|
||||
"inetOrgPerson",
|
||||
"posixAccount",
|
||||
"person",
|
||||
"organizationalPerson",
|
||||
]
|
||||
attributes = {
|
||||
"sn": user.firstname,
|
||||
"givenName": user.lastname,
|
||||
"gidNumber": self.default_gid,
|
||||
"homeDirectory": f"/home/{user.userid}",
|
||||
"loginShell": "/bin/bash",
|
||||
"uid": user.userid,
|
||||
"userPassword": hashed(HASHED_SALTED_MD5, password),
|
||||
"uidNumber": uid_number,
|
||||
}
|
||||
ldap_conn.add(dn, object_class, attributes)
|
||||
if user.display_name:
|
||||
attributes.update({"displayName": user.display_name})
|
||||
ldap_conn.add(dn, self.object_classes, attributes)
|
||||
self._set_roles(user)
|
||||
self.update_user(user)
|
||||
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
||||
raise BadRequest
|
||||
|
||||
|
@ -110,10 +127,10 @@ class AuthLDAP(AuthPlugin):
|
|||
if password:
|
||||
ldap_conn = self.ldap.connect(dn, password)
|
||||
else:
|
||||
if self.admin_dn is None:
|
||||
logger.error("admin_dn missing in ldap config!")
|
||||
if self.root_dn is None:
|
||||
logger.error("root_dn missing in ldap config!")
|
||||
raise InternalServerError
|
||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
||||
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
modifier = {}
|
||||
for name, ldap_name in [
|
||||
("firstname", "givenName"),
|
||||
|
@ -124,9 +141,7 @@ class AuthLDAP(AuthPlugin):
|
|||
if hasattr(user, name):
|
||||
modifier[ldap_name] = [(MODIFY_REPLACE, [getattr(user, name)])]
|
||||
if new_password:
|
||||
# TODO: Use secure hash!
|
||||
salted_password = hashed(HASHED_SALTED_MD5, new_password)
|
||||
modifier["userPassword"] = [(MODIFY_REPLACE, [salted_password])]
|
||||
modifier["userPassword"] = [(MODIFY_REPLACE, [self.__hash(new_password)])]
|
||||
ldap_conn.modify(dn, modifier)
|
||||
self._set_roles(user)
|
||||
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
||||
|
@ -134,7 +149,7 @@ class AuthLDAP(AuthPlugin):
|
|||
|
||||
def get_avatar(self, user):
|
||||
self.ldap.connection.search(
|
||||
"ou=user,{}".format(self.dn),
|
||||
self.search_dn,
|
||||
"(uid={})".format(user.userid),
|
||||
SUBTREE,
|
||||
attributes=["jpegPhoto"],
|
||||
|
@ -144,14 +159,14 @@ class AuthLDAP(AuthPlugin):
|
|||
if "jpegPhoto" in r and len(r["jpegPhoto"]) > 0:
|
||||
avatar = _Avatar()
|
||||
avatar.mimetype = "image/jpeg"
|
||||
avatar.binary.extend(r["jpegPhoto"][0])
|
||||
avatar.binary = bytearray(r["jpegPhoto"][0])
|
||||
return avatar
|
||||
else:
|
||||
raise NotFound
|
||||
|
||||
def set_avatar(self, user, avatar: _Avatar):
|
||||
if self.admin_dn is None:
|
||||
logger.error("admin_dn missing in ldap config!")
|
||||
if self.root_dn is None:
|
||||
logger.error("root_dn missing in ldap config!")
|
||||
raise InternalServerError
|
||||
|
||||
if avatar.mimetype != "image/jpeg":
|
||||
|
@ -172,16 +187,23 @@ class AuthLDAP(AuthPlugin):
|
|||
raise BadRequest("Unsupported image format")
|
||||
|
||||
dn = user.get_attribute("DN")
|
||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
||||
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [avatar.binary])]})
|
||||
|
||||
def delete_avatar(self, user):
|
||||
if self.root_dn is None:
|
||||
logger.error("root_dn missing in ldap config!")
|
||||
dn = user.get_attribute("DN")
|
||||
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
ldap_conn.modify(dn, {"jpegPhoto": [(MODIFY_REPLACE, [])]})
|
||||
|
||||
def __find(self, userid, mail=None):
|
||||
"""Find attributes of an user by uid or mail in LDAP"""
|
||||
con = self.ldap.connection
|
||||
if not con:
|
||||
con = self.ldap.connect(self.admin_dn, self.admin_secret)
|
||||
con = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
con.search(
|
||||
f"ou=user,{self.dn}",
|
||||
self.search_dn,
|
||||
f"(| (uid={userid})(mail={mail}))" if mail else f"(uid={userid})",
|
||||
SUBTREE,
|
||||
attributes=["uid", "givenName", "sn", "mail"],
|
||||
|
@ -205,12 +227,12 @@ class AuthLDAP(AuthPlugin):
|
|||
role: Role,
|
||||
new_name: Optional[str],
|
||||
):
|
||||
if self.admin_dn is None:
|
||||
logger.error("admin_dn missing in ldap config!")
|
||||
if self.root_dn is None:
|
||||
logger.error("root_dn missing in ldap config!")
|
||||
raise InternalServerError
|
||||
try:
|
||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
||||
ldap_conn.search(f"ou=group,{self.dn}", f"(cn={role.name})", SUBTREE, attributes=["cn"])
|
||||
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
ldap_conn.search(self.group_dn, f"(cn={role.name})", SUBTREE, attributes=["cn"])
|
||||
if len(ldap_conn.response) > 0:
|
||||
dn = ldap_conn.response[0]["dn"]
|
||||
if new_name:
|
||||
|
@ -218,13 +240,33 @@ class AuthLDAP(AuthPlugin):
|
|||
else:
|
||||
ldap_conn.delete(dn)
|
||||
|
||||
except (LDAPPasswordIsMandatoryError, LDAPBindError):
|
||||
except LDAPPasswordIsMandatoryError:
|
||||
raise BadRequest
|
||||
except LDAPBindError:
|
||||
logger.debug(f"Could not bind to LDAP server", exc_info=True)
|
||||
raise InternalServerError
|
||||
|
||||
def __hash(self, password):
|
||||
if self.password_hash == "ARGON2":
|
||||
from argon2 import PasswordHasher
|
||||
|
||||
return f"{{ARGON2}}{PasswordHasher().hash(password)}"
|
||||
else:
|
||||
from hashlib import pbkdf2_hmac, sha1
|
||||
import base64
|
||||
|
||||
salt = os.urandom(16)
|
||||
if self.password_hash == "PBKDF2":
|
||||
rounds = 200000
|
||||
password_hash = base64.b64encode(pbkdf2_hmac("sha512", password.encode("utf-8"), salt, rounds)).decode()
|
||||
return f"{{PBKDF2-SHA512}}{rounds}${base64.b64encode(salt).decode()}${password_hash}"
|
||||
else:
|
||||
return f"{{SSHA}}{base64.b64encode(sha1(password.encode() + salt).digest() + salt).decode()}"
|
||||
|
||||
def _get_groups(self, uid):
|
||||
groups = []
|
||||
self.ldap.connection.search(
|
||||
"ou=group,{}".format(self.dn),
|
||||
self.group_dn,
|
||||
"(memberUID={})".format(uid),
|
||||
SUBTREE,
|
||||
attributes=["cn"],
|
||||
|
@ -236,7 +278,7 @@ class AuthLDAP(AuthPlugin):
|
|||
|
||||
def _get_all_roles(self):
|
||||
self.ldap.connection.search(
|
||||
f"ou=group,{self.dn}",
|
||||
self.group_dn,
|
||||
"(cn=*)",
|
||||
SUBTREE,
|
||||
attributes=["cn", "gidNumber", "memberUid"],
|
||||
|
@ -245,8 +287,7 @@ class AuthLDAP(AuthPlugin):
|
|||
|
||||
def _set_roles(self, user: User):
|
||||
try:
|
||||
ldap_conn = self.ldap.connect(self.admin_dn, self.admin_secret)
|
||||
|
||||
ldap_conn = self.ldap.connect(self.root_dn, self.root_secret)
|
||||
ldap_roles = self._get_all_roles()
|
||||
|
||||
gid_numbers = sorted(ldap_roles, key=lambda i: i["attributes"]["gidNumber"], reverse=True)
|
||||
|
@ -255,7 +296,7 @@ class AuthLDAP(AuthPlugin):
|
|||
for user_role in user.roles:
|
||||
if user_role not in [role["attributes"]["cn"][0] for role in ldap_roles]:
|
||||
ldap_conn.add(
|
||||
f"cn={user_role},ou=group,{self.dn}",
|
||||
f"cn={user_role},{self.group_dn}",
|
||||
["posixGroup"],
|
||||
attributes={"gidNumber": gid_number},
|
||||
)
|
||||
|
|
|
@ -19,7 +19,13 @@ class AuthPlain(AuthPlugin):
|
|||
if User.query.first() is None:
|
||||
logger.info("Installing admin user")
|
||||
role = Role(name="Superuser", permissions=Permission.query.all())
|
||||
admin = User(userid="admin", firstname="Admin", lastname="Admin", mail="", roles_=[role])
|
||||
admin = User(
|
||||
userid="admin",
|
||||
firstname="Admin",
|
||||
lastname="Admin",
|
||||
mail="",
|
||||
roles_=[role],
|
||||
)
|
||||
self.modify_user(admin, None, "admin")
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
|
@ -58,6 +64,9 @@ class AuthPlain(AuthPlugin):
|
|||
def set_avatar(self, user, avatar):
|
||||
user.set_attribute("avatar", avatar)
|
||||
|
||||
def delete_avatar(self, user):
|
||||
user.delete_attribute("avatar")
|
||||
|
||||
@staticmethod
|
||||
def _hash_password(password):
|
||||
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode("ascii")
|
||||
|
|
|
@ -3,12 +3,13 @@
|
|||
# English: Debit -> from 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 werkzeug.exceptions import BadRequest, NotFound, Conflict
|
||||
|
||||
from flaschengeist.database import db
|
||||
from flaschengeist.models.user import User
|
||||
from flaschengeist.models.user import User, _UserAttribute
|
||||
|
||||
from .models import Transaction
|
||||
from . import permissions, BalancePlugin
|
||||
|
@ -38,27 +39,114 @@ def get_balance(user, start: datetime = None, end: datetime = None):
|
|||
return credit, debit, credit - debit
|
||||
|
||||
|
||||
def get_balances(start: datetime = None, end: datetime = None):
|
||||
debit = db.session.query(Transaction.sender_id, func.sum(Transaction.amount)).filter(Transaction.sender_ != None)
|
||||
credit = db.session.query(Transaction.receiver_id, func.sum(Transaction.amount)).filter(
|
||||
Transaction.receiver_ != None
|
||||
)
|
||||
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)
|
||||
def get_balances(start: datetime = None, end: datetime = None, limit=None, offset=None, descending=None, sortBy=None):
|
||||
class _User(User):
|
||||
_debit = db.relationship(Transaction, back_populates="sender_", foreign_keys=[Transaction._sender_id])
|
||||
_credit = db.relationship(Transaction, back_populates="receiver_", foreign_keys=[Transaction._receiver_id])
|
||||
|
||||
debit = debit.group_by(Transaction._sender_id).all()
|
||||
credit = credit.group_by(Transaction._receiver_id).all()
|
||||
@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:
|
||||
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 = {}
|
||||
for uid, cred in credit:
|
||||
all[uid] = [cred, 0]
|
||||
for uid, deb in debit:
|
||||
all.setdefault(uid, [0, 0])
|
||||
all[uid][1] = deb
|
||||
return all
|
||||
|
||||
for user in users:
|
||||
|
||||
all[user.userid] = [user.get_credit(start, end), 0]
|
||||
all[user.userid][1] = user.get_debit(start, end)
|
||||
|
||||
return all, count
|
||||
|
||||
|
||||
def send(sender: User, receiver, amount: float, author: User):
|
||||
|
@ -113,7 +201,16 @@ def get_transaction(transaction_id) -> Transaction:
|
|||
return transaction
|
||||
|
||||
|
||||
def get_transactions(user, start=None, end=None, limit=None, offset=None, show_reversal=False, show_cancelled=True):
|
||||
def get_transactions(
|
||||
user,
|
||||
start=None,
|
||||
end=None,
|
||||
limit=None,
|
||||
offset=None,
|
||||
show_reversal=False,
|
||||
show_cancelled=True,
|
||||
descending=False,
|
||||
):
|
||||
count = None
|
||||
query = Transaction.query.filter((Transaction.sender_ == user) | (Transaction.receiver_ == user))
|
||||
if start:
|
||||
|
@ -125,7 +222,10 @@ def get_transactions(user, start=None, end=None, limit=None, offset=None, show_r
|
|||
query = query.filter(Transaction.original_ == None)
|
||||
if not show_cancelled:
|
||||
query = query.filter(Transaction.reversal_id.is_(None))
|
||||
query = query.order_by(Transaction.time)
|
||||
if descending:
|
||||
query = query.order_by(Transaction.time.desc())
|
||||
else:
|
||||
query = query.order_by(Transaction.time)
|
||||
if limit is not None:
|
||||
count = query.count()
|
||||
query = query.limit(limit)
|
||||
|
|
|
@ -99,6 +99,31 @@ def set_limit(userid, current_session: Session):
|
|||
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"])
|
||||
@login_required(permission=permissions.SHOW)
|
||||
def get_balance(userid, current_session: Session):
|
||||
|
@ -170,6 +195,7 @@ def get_transactions(userid, current_session: Session):
|
|||
show_cancelled = request.args.get("showCancelled", True)
|
||||
limit = request.args.get("limit")
|
||||
offset = request.args.get("offset")
|
||||
descending = request.args.get("descending", False)
|
||||
try:
|
||||
if limit is not None:
|
||||
limit = int(limit)
|
||||
|
@ -179,11 +205,20 @@ def get_transactions(userid, current_session: Session):
|
|||
show_reversals = str2bool(show_reversals)
|
||||
if not isinstance(show_cancelled, bool):
|
||||
show_cancelled = str2bool(show_cancelled)
|
||||
if not isinstance(descending, bool):
|
||||
descending = str2bool(descending)
|
||||
except ValueError:
|
||||
raise BadRequest
|
||||
|
||||
transactions, count = balance_controller.get_transactions(
|
||||
user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled
|
||||
user,
|
||||
start,
|
||||
end,
|
||||
limit,
|
||||
offset,
|
||||
show_reversal=show_reversals,
|
||||
show_cancelled=show_cancelled,
|
||||
descending=descending,
|
||||
)
|
||||
return {"transactions": transactions, "count": count}
|
||||
|
||||
|
@ -275,5 +310,14 @@ def get_balances(current_session: Session):
|
|||
Returns:
|
||||
JSON Array containing credit, debit and userid for each user or HTTP error
|
||||
"""
|
||||
balances = balance_controller.get_balances()
|
||||
return jsonify([{"userid": u, "credit": v[0], "debit": v[1]} for u, v in balances.items()])
|
||||
limit = request.args.get("limit", type=int)
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
|
|
@ -11,6 +11,7 @@ from . import permissions, models
|
|||
|
||||
class EventPlugin(Plugin):
|
||||
name = "events"
|
||||
id = "dev.flaschengeist.events"
|
||||
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name])
|
||||
permissions = permissions.permissions
|
||||
blueprint = Blueprint(name, __name__)
|
||||
|
|
|
@ -1,16 +1,32 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
from enum import IntEnum
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
from werkzeug.exceptions import BadRequest, Conflict, NotFound
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm.util import was_deleted
|
||||
|
||||
from flaschengeist import logger
|
||||
from flaschengeist.database import db
|
||||
from flaschengeist.plugins.events import EventPlugin
|
||||
from flaschengeist.plugins.events.models import EventType, Event, Job, JobType, Service
|
||||
from flaschengeist.plugins.events.models import EventType, Event, Invitation, Job, JobType, Service
|
||||
from flaschengeist.utils.scheduler import scheduled
|
||||
|
||||
|
||||
# STUB
|
||||
def _(x):
|
||||
return x
|
||||
|
||||
|
||||
class NotifyType(IntEnum):
|
||||
# Invitations 0x00..0x0F
|
||||
INVITE = 0x01
|
||||
TRANSFER = 0x02
|
||||
# Invitation responsed 0x10..0x1F
|
||||
INVITATION_ACCEPTED = 0x10
|
||||
INVITATION_REJECTED = 0x11
|
||||
|
||||
|
||||
def update():
|
||||
db.session.commit()
|
||||
|
||||
|
@ -41,7 +57,7 @@ def create_event_type(name):
|
|||
db.session.commit()
|
||||
return event
|
||||
except IntegrityError:
|
||||
raise BadRequest("Name already exists")
|
||||
raise Conflict("Name already exists")
|
||||
|
||||
|
||||
def rename_event_type(identifier, new_name):
|
||||
|
@ -50,7 +66,7 @@ def rename_event_type(identifier, new_name):
|
|||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
raise BadRequest("Name already exists")
|
||||
raise Conflict("Name already exists")
|
||||
|
||||
|
||||
def delete_event_type(name):
|
||||
|
@ -116,7 +132,7 @@ def get_event(event_id, with_backup=False) -> Event:
|
|||
if event is None:
|
||||
raise NotFound
|
||||
if not with_backup:
|
||||
return clear_backup(event)
|
||||
clear_backup(event)
|
||||
return event
|
||||
|
||||
|
||||
|
@ -124,7 +140,14 @@ def get_templates():
|
|||
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,
|
||||
) -> Tuple[int, list[Event]]:
|
||||
"""Query events which start from begin until end
|
||||
Args:
|
||||
start (datetime): Earliest start
|
||||
|
@ -138,11 +161,23 @@ def get_events(start: Optional[datetime] = None, end=None, with_backup=False):
|
|||
query = query.filter(start <= Event.start)
|
||||
if end is not None:
|
||||
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()
|
||||
if not with_backup:
|
||||
for event in events:
|
||||
clear_backup(event)
|
||||
return events
|
||||
return count, events
|
||||
|
||||
|
||||
def delete_event(event_id):
|
||||
|
@ -153,7 +188,9 @@ def delete_event(event_id):
|
|||
Raises:
|
||||
NotFound if not found
|
||||
"""
|
||||
event = get_event(event_id)
|
||||
event = get_event(event_id, True)
|
||||
for job in event.jobs:
|
||||
delete_job(job)
|
||||
db.session.delete(event)
|
||||
db.session.commit()
|
||||
|
||||
|
@ -178,15 +215,42 @@ def create_event(event_type, start, end=None, jobs=[], is_template=None, name=No
|
|||
raise BadRequest
|
||||
|
||||
|
||||
def get_job(job_slot_id, event_id):
|
||||
js = Job.query.filter(Job.id == job_slot_id).filter(Job.event_id_ == event_id).one_or_none()
|
||||
if js is None:
|
||||
def get_job(job_id, event_id=None) -> Job:
|
||||
query = Job.query.filter(Job.id == job_id)
|
||||
if event_id is not None:
|
||||
query = query.filter(Job.event_id_ == event_id)
|
||||
job = query.one_or_none()
|
||||
if job is None:
|
||||
raise NotFound
|
||||
return js
|
||||
return job
|
||||
|
||||
|
||||
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):
|
||||
job = Job(required_services=required_services, type=job_type, start=start, end=end, comment=comment)
|
||||
job = Job(
|
||||
required_services=required_services,
|
||||
type=job_type,
|
||||
start=start,
|
||||
end=end,
|
||||
comment=comment,
|
||||
)
|
||||
event.jobs.append(job)
|
||||
update()
|
||||
return job
|
||||
|
@ -196,30 +260,113 @@ def update():
|
|||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
logger.debug("Error, looks like a Job with that type already exists on an event", exc_info=True)
|
||||
logger.debug(
|
||||
"Error, looks like a Job with that type already exists on an event",
|
||||
exc_info=True,
|
||||
)
|
||||
raise BadRequest()
|
||||
|
||||
|
||||
def delete_job(job: Job):
|
||||
for service in job.services:
|
||||
unassign_job(service=service, notify=True)
|
||||
for invitation in job.invitations_:
|
||||
respond_invitation(invitation, False)
|
||||
db.session.delete(job)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def assign_to_job(job: Job, user, value):
|
||||
def assign_job(job: Job, user, value, is_backup=False):
|
||||
assert value > 0
|
||||
service = Service.query.get((job.id, user.id_))
|
||||
if value < 0:
|
||||
if not service:
|
||||
raise BadRequest
|
||||
db.session.delete(service)
|
||||
if service:
|
||||
service.value = value
|
||||
else:
|
||||
if service:
|
||||
service.value = value
|
||||
else:
|
||||
service = Service(user_=user, value=value, job_=job)
|
||||
db.session.add(service)
|
||||
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:
|
||||
raise BadRequest
|
||||
|
||||
event_id = service.job_.event_id_
|
||||
|
||||
db.session.delete(service)
|
||||
db.session.commit()
|
||||
if notify:
|
||||
EventPlugin.plugin.notify(user, "Your assignmet was cancelled", {"event_id": event_id})
|
||||
|
||||
|
||||
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:
|
||||
EventPlugin.plugin.notify(invitee, _("Job transfer"), {"type": NotifyType.TRANSFER, "invitation": inv.id})
|
||||
return inv
|
||||
|
||||
|
||||
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:
|
||||
if invite.transferee_id is None:
|
||||
assign_job(job, invite.invitee_, 1)
|
||||
else:
|
||||
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
|
||||
def assign_backups():
|
||||
logger.debug("Notifications")
|
||||
|
@ -230,7 +377,9 @@ def assign_backups():
|
|||
for service in services:
|
||||
if service.job_.start <= now or service.job_.is_full():
|
||||
EventPlugin.plugin.notify(
|
||||
service.user_, "Your backup assignment was cancelled.", {"event_id": service.job_.event_id_}
|
||||
service.user_,
|
||||
"Your backup assignment was cancelled.",
|
||||
{"event_id": service.job_.event_id_},
|
||||
)
|
||||
logger.debug(f"Service is outdated or full, removing. {service.serialize()}")
|
||||
db.session.delete(service)
|
||||
|
@ -238,6 +387,8 @@ def assign_backups():
|
|||
service.is_backup = False
|
||||
logger.debug(f"Service not full, assigning backup. {service.serialize()}")
|
||||
EventPlugin.plugin.notify(
|
||||
service.user_, "Your backup assignment was accepted.", {"event_id": service.job_.event_id_}
|
||||
service.user_,
|
||||
"Your backup assignment was accepted.",
|
||||
{"event_id": service.job_.event_id_},
|
||||
)
|
||||
db.session.commit()
|
||||
|
|
|
@ -39,7 +39,13 @@ class Service(db.Model, ModelSerializeMixin):
|
|||
is_backup: bool = db.Column(db.Boolean, default=False)
|
||||
value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False)
|
||||
|
||||
_job_id = db.Column("job_id", Serial, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True)
|
||||
_job_id = db.Column(
|
||||
"job_id",
|
||||
Serial,
|
||||
db.ForeignKey(f"{_table_prefix_}job.id"),
|
||||
nullable=False,
|
||||
primary_key=True,
|
||||
)
|
||||
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True)
|
||||
|
||||
user_: User = db.relationship("User")
|
||||
|
@ -52,18 +58,23 @@ class Service(db.Model, ModelSerializeMixin):
|
|||
|
||||
class Job(db.Model, ModelSerializeMixin):
|
||||
__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)
|
||||
start: datetime = db.Column(UtcDateTime, nullable=False)
|
||||
end: Optional[datetime] = db.Column(UtcDateTime)
|
||||
type: Union[JobType, int] = db.relationship("JobType")
|
||||
comment: Optional[str] = db.Column(db.String(256))
|
||||
services: list[Service] = db.relationship("Service", back_populates="job_")
|
||||
locked: bool = db.Column(db.Boolean(), default=False, nullable=False)
|
||||
services: list[Service] = db.relationship(
|
||||
"Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan"
|
||||
)
|
||||
required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False)
|
||||
|
||||
event_ = db.relationship("Event", back_populates="jobs")
|
||||
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"),)
|
||||
|
||||
|
@ -83,33 +94,47 @@ class Event(db.Model, ModelSerializeMixin):
|
|||
type: Union[EventType, int] = db.relationship("EventType")
|
||||
is_template: bool = db.Column(db.Boolean, default=False)
|
||||
jobs: list[Job] = db.relationship(
|
||||
"Job", back_populates="event_", cascade="all,delete,delete-orphan", order_by="[Job.start, Job.end]"
|
||||
"Job",
|
||||
back_populates="event_",
|
||||
cascade="all,delete,delete-orphan",
|
||||
order_by="[Job.start, Job.end]",
|
||||
)
|
||||
# Protected for internal use
|
||||
_type_id = db.Column(
|
||||
"type_id", Serial, db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"), nullable=False
|
||||
"type_id",
|
||||
Serial,
|
||||
db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
|
||||
class Invite(db.Model, ModelSerializeMixin):
|
||||
__tablename__ = _table_prefix_ + "invite"
|
||||
class Invitation(db.Model, ModelSerializeMixin):
|
||||
__tablename__ = _table_prefix_ + "invitation"
|
||||
|
||||
id: int = db.Column(Serial, primary_key=True)
|
||||
job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False)
|
||||
# Dummy properties for API export
|
||||
invitee_id: str = None
|
||||
sender_id: str = None
|
||||
invitee_id: str = None # User who was invited to take over
|
||||
inviter_id: str = None # User who invited the invitee
|
||||
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
|
||||
invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id")
|
||||
sender_: User = db.relationship("User", foreign_keys="Invite._sender_id")
|
||||
job_: Job = db.relationship(Job, foreign_keys="Invitation.job_id")
|
||||
invitee_: User = db.relationship("User", foreign_keys="Invitation._invitee_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
|
||||
_invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False)
|
||||
_sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id"), nullable=False)
|
||||
_inviter_id = db.Column("inviter_id", Serial, db.ForeignKey("user.id"), nullable=False)
|
||||
_transferee_id = db.Column("transferee_id", Serial, db.ForeignKey("user.id"))
|
||||
|
||||
@property
|
||||
def invitee_id(self):
|
||||
return self.invitee_.userid
|
||||
|
||||
@property
|
||||
def sender_id(self):
|
||||
return self.sender_.userid
|
||||
def inviter_id(self):
|
||||
return self.inviter_.userid
|
||||
|
||||
@property
|
||||
def transferee_id(self):
|
||||
return self.transferee_.userid if self.transferee_ else None
|
||||
|
|
|
@ -22,4 +22,7 @@ ASSIGN_OTHER = "events_assign_other"
|
|||
SEE_BACKUP = "events_see_backup"
|
||||
"""Can see users assigned as backup"""
|
||||
|
||||
LOCK_JOBS = "events_lock_jobs"
|
||||
"""Can lock jobs, no further services can be assigned or unassigned"""
|
||||
|
||||
permissions = [value for key, value in globals().items() if not key.startswith("_")]
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from http.client import NO_CONTENT
|
||||
from flask import request, jsonify
|
||||
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
|
||||
|
@ -9,7 +8,21 @@ from flaschengeist.utils.datetime import from_iso_format
|
|||
from flaschengeist.controller import userController
|
||||
|
||||
from . import event_controller, permissions, EventPlugin
|
||||
from ...utils.HTTP import no_content
|
||||
from ...utils.HTTP import get_filter_args, 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"])
|
||||
|
@ -169,72 +182,26 @@ def get_event(event_id, current_session):
|
|||
JSON encoded event object
|
||||
"""
|
||||
event = event_controller.get_event(
|
||||
event_id, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP)
|
||||
event_id,
|
||||
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
|
||||
)
|
||||
return jsonify(event)
|
||||
|
||||
|
||||
@EventPlugin.blueprint.route("/events", methods=["GET"])
|
||||
@login_required()
|
||||
def get_filtered_events(current_session):
|
||||
begin = request.args.get("from")
|
||||
if begin is not None:
|
||||
begin = from_iso_format(begin)
|
||||
end = request.args.get("to")
|
||||
if end is not None:
|
||||
end = from_iso_format(end)
|
||||
if begin is None and end is None:
|
||||
begin = datetime.now()
|
||||
return jsonify(
|
||||
event_controller.get_events(
|
||||
begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP)
|
||||
)
|
||||
def get_events(current_session):
|
||||
count, result = event_controller.get_events(
|
||||
*get_filter_args(),
|
||||
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
|
||||
)
|
||||
|
||||
|
||||
@EventPlugin.blueprint.route("/events/<int:year>/<int:month>", methods=["GET"])
|
||||
@EventPlugin.blueprint.route("/events/<int:year>/<int:month>/<int:day>", methods=["GET"])
|
||||
@login_required()
|
||||
def get_events(current_session, year=datetime.now().year, month=datetime.now().month, day=None):
|
||||
"""Get Event objects for specified date (or month or year),
|
||||
if nothing set then events for current month are returned
|
||||
|
||||
Route: ``/events[/<year>/<month>[/<int:day>]]`` | Method: ``GET``
|
||||
|
||||
Args:
|
||||
year (int, optional): year to query, defaults to current year
|
||||
month (int, optional): month to query (if set), defaults to current month
|
||||
day (int, optional): day to query events for (if set)
|
||||
current_session: Session sent with Authorization Header
|
||||
|
||||
Returns:
|
||||
JSON encoded list containing events found or HTTP-error
|
||||
"""
|
||||
try:
|
||||
begin = datetime(year=year, month=month, day=1, tzinfo=timezone.utc)
|
||||
if day:
|
||||
begin += timedelta(days=day - 1)
|
||||
end = begin + timedelta(days=1)
|
||||
else:
|
||||
if month == 12:
|
||||
end = datetime(year=year + 1, month=1, day=1, tzinfo=timezone.utc)
|
||||
else:
|
||||
end = datetime(year=year, month=month + 1, day=1, tzinfo=timezone.utc)
|
||||
|
||||
events = event_controller.get_events(
|
||||
begin, end, with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP)
|
||||
)
|
||||
return jsonify(events)
|
||||
except ValueError:
|
||||
raise BadRequest("Invalid date given")
|
||||
return jsonify({"count": count, "result": result})
|
||||
|
||||
|
||||
def _add_job(event, data):
|
||||
try:
|
||||
start = from_iso_format(data["start"])
|
||||
end = None
|
||||
if "end" in data:
|
||||
end = from_iso_format(data["end"])
|
||||
end = dict_get(data, "end", None, type=from_iso_format)
|
||||
required_services = data["required_services"]
|
||||
job_type = data["type"]
|
||||
if isinstance(job_type, dict):
|
||||
|
@ -243,7 +210,14 @@ def _add_job(event, data):
|
|||
raise BadRequest("Missing or invalid POST parameter")
|
||||
|
||||
job_type = event_controller.get_job_type(job_type)
|
||||
event_controller.add_job(event, job_type, required_services, start, end, comment=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"])
|
||||
|
@ -262,11 +236,9 @@ def create_event(current_session):
|
|||
JSON encoded Event object or HTTP-error
|
||||
"""
|
||||
data = request.get_json()
|
||||
end = data.get("end", None)
|
||||
try:
|
||||
start = from_iso_format(data["start"])
|
||||
if end is not None:
|
||||
end = from_iso_format(end)
|
||||
end = dict_get(data, "end", None, type=from_iso_format)
|
||||
data_type = data["type"]
|
||||
if isinstance(data_type, dict):
|
||||
data_type = data["type"]["id"]
|
||||
|
@ -279,10 +251,10 @@ def create_event(current_session):
|
|||
event = event_controller.create_event(
|
||||
start=start,
|
||||
end=end,
|
||||
name=data.get("name", None),
|
||||
is_template=data.get("is_template", None),
|
||||
name=dict_get(data, "name", None),
|
||||
is_template=dict_get(data, "is_template", None),
|
||||
event_type=event_type,
|
||||
description=data.get("description", None),
|
||||
description=dict_get(data, "description", None),
|
||||
)
|
||||
if "jobs" in data:
|
||||
for job in data["jobs"]:
|
||||
|
@ -309,15 +281,14 @@ def modify_event(event_id, current_session):
|
|||
"""
|
||||
event = event_controller.get_event(event_id)
|
||||
data = request.get_json()
|
||||
if "start" in data:
|
||||
event.start = from_iso_format(data["start"])
|
||||
if "end" in data:
|
||||
event.end = from_iso_format(data["end"])
|
||||
if "description" in data:
|
||||
event.description = data["description"]
|
||||
event.start = dict_get(data, "start", event.start, type=from_iso_format)
|
||||
event.end = dict_get(data, "end", event.end, type=from_iso_format)
|
||||
event.name = dict_get(data, "name", event.name, type=str)
|
||||
event.description = dict_get(data, "description", event.description, type=str)
|
||||
if "type" in data:
|
||||
event_type = event_controller.get_event_type(data["type"])
|
||||
event.type = event_type
|
||||
|
||||
event_controller.update()
|
||||
return jsonify(event)
|
||||
|
||||
|
@ -376,19 +347,19 @@ def delete_job(event_id, job_id, current_session):
|
|||
Returns:
|
||||
HTTP-no-content or HTTP error
|
||||
"""
|
||||
job_slot = event_controller.get_job(job_id, event_id)
|
||||
event_controller.delete_job(job_slot)
|
||||
job = event_controller.get_job(job_id, event_id)
|
||||
event_controller.delete_job(job)
|
||||
return no_content()
|
||||
|
||||
|
||||
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"])
|
||||
@login_required()
|
||||
def update_job(event_id, job_id, current_session: Session):
|
||||
"""Edit Job or assign user to the Job
|
||||
"""Edit Job
|
||||
|
||||
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:
|
||||
event_id: Identifier of the event
|
||||
|
@ -398,34 +369,160 @@ def update_job(event_id, job_id, current_session: Session):
|
|||
Returns:
|
||||
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()
|
||||
if not data:
|
||||
raise BadRequest
|
||||
|
||||
if ("user" not in data or len(data) > 1) and not current_session.user_.has_permission(permissions.EDIT):
|
||||
raise Forbidden
|
||||
|
||||
if "user" in data:
|
||||
try:
|
||||
user = userController.get_user(data["user"]["userid"])
|
||||
value = data["user"]["value"]
|
||||
if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or (
|
||||
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
|
||||
):
|
||||
raise Forbidden
|
||||
event_controller.assign_to_job(job, user, value)
|
||||
except (KeyError, ValueError):
|
||||
raise BadRequest
|
||||
|
||||
if "required_services" in data:
|
||||
job.required_services = data["required_services"]
|
||||
if "type" in data:
|
||||
job.type = event_controller.get_job_type(data["type"])
|
||||
event_controller.update()
|
||||
job = event_controller.get_job(job_id, event_id)
|
||||
try:
|
||||
if "type" in data:
|
||||
job.type = event_controller.get_job_type(data["type"])
|
||||
job.start = from_iso_format(data.get("start", job.start))
|
||||
job.end = from_iso_format(data.get("end", job.end))
|
||||
job.comment = str(data.get("comment", job.comment))
|
||||
job.locked = bool(data.get("locked", job.locked))
|
||||
job.required_services = float(data.get("required_services", job.required_services))
|
||||
event_controller.update()
|
||||
except NotFound:
|
||||
raise BadRequest("Invalid JobType")
|
||||
except ValueError:
|
||||
raise BadRequest("Invalid POST data")
|
||||
|
||||
return jsonify(job)
|
||||
|
||||
|
||||
# TODO: JobTransfer
|
||||
@EventPlugin.blueprint.route("/events/jobs", methods=["GET"])
|
||||
@login_required()
|
||||
def get_jobs(current_session: Session):
|
||||
count, result = event_controller.get_jobs(current_session.user_, *get_filter_args())
|
||||
return jsonify({"count": count, "result": result})
|
||||
|
||||
|
||||
@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:
|
||||
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
|
||||
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 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()
|
||||
|
|
|
@ -2,13 +2,14 @@
|
|||
|
||||
from flask import Blueprint, jsonify, request, current_app
|
||||
from werkzeug.local import LocalProxy
|
||||
from werkzeug.exceptions import BadRequest, Forbidden
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
|
||||
|
||||
from flaschengeist.plugins import Plugin
|
||||
from flaschengeist.utils.decorators import login_required
|
||||
from flaschengeist.utils.HTTP import no_content
|
||||
from flaschengeist.models.session import Session
|
||||
from flaschengeist import logger
|
||||
from flaschengeist.controller import userController
|
||||
from flaschengeist.controller.imageController import send_image, send_thumbnail
|
||||
from flaschengeist.plugins import Plugin
|
||||
from flaschengeist.utils.decorators import login_required, extract_session, headers
|
||||
from flaschengeist.utils.HTTP import no_content
|
||||
|
||||
from . import models
|
||||
from . import pricelist_controller, permissions
|
||||
|
@ -16,6 +17,7 @@ from . import pricelist_controller, permissions
|
|||
|
||||
class PriceListPlugin(Plugin):
|
||||
name = "pricelist"
|
||||
permissions = permissions.permissions
|
||||
blueprint = Blueprint(name, __name__, url_prefix="/pricelist")
|
||||
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name])
|
||||
models = models
|
||||
|
@ -29,6 +31,17 @@ class PriceListPlugin(Plugin):
|
|||
@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"])
|
||||
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["GET"])
|
||||
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:
|
||||
result = pricelist_controller.get_drink_types()
|
||||
else:
|
||||
|
@ -39,6 +52,18 @@ def get_drink_types(identifier=None):
|
|||
@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"])
|
||||
@login_required(permission=permissions.CREATE_TYPE)
|
||||
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()
|
||||
if "name" not in data:
|
||||
raise BadRequest
|
||||
|
@ -49,6 +74,19 @@ def new_drink_type(current_session):
|
|||
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["PUT"])
|
||||
@login_required(permission=permissions.EDIT_TYPE)
|
||||
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()
|
||||
if "name" not in data:
|
||||
raise BadRequest
|
||||
|
@ -59,6 +97,17 @@ def update_drink_type(identifier, current_session):
|
|||
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"])
|
||||
@login_required(permission=permissions.DELETE_TYPE)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
@ -66,6 +115,17 @@ def delete_drink_type(identifier, current_session):
|
|||
@PriceListPlugin.blueprint.route("/tags", methods=["GET"])
|
||||
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["GET"])
|
||||
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:
|
||||
result = pricelist_controller.get_tag(identifier)
|
||||
else:
|
||||
|
@ -76,26 +136,58 @@ def get_tags(identifier=None):
|
|||
@PriceListPlugin.blueprint.route("/tags", methods=["POST"])
|
||||
@login_required(permission=permissions.CREATE_TAG)
|
||||
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()
|
||||
if "name" not in data:
|
||||
raise BadRequest
|
||||
drink_type = pricelist_controller.create_tag(data["name"])
|
||||
drink_type = pricelist_controller.create_tag(data)
|
||||
return jsonify(drink_type)
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["PUT"])
|
||||
@login_required(permission=permissions.EDIT_TAG)
|
||||
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()
|
||||
if "name" not in data:
|
||||
raise BadRequest
|
||||
tag = pricelist_controller.rename_tag(identifier, data["name"])
|
||||
tag = pricelist_controller.update_tag(identifier, data)
|
||||
return jsonify(tag)
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["DELETE"])
|
||||
@login_required(permission=permissions.DELETE_TAG)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
@ -103,95 +195,428 @@ def delete_tag(identifier, current_session):
|
|||
@PriceListPlugin.blueprint.route("/drinks", methods=["GET"])
|
||||
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["GET"])
|
||||
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:
|
||||
result = pricelist_controller.get_drink(identifier)
|
||||
result = pricelist_controller.get_drink(identifier, public=public)
|
||||
return jsonify(result)
|
||||
else:
|
||||
result = pricelist_controller.get_drinks()
|
||||
return jsonify(result)
|
||||
limit = request.args.get("limit")
|
||||
offset = request.args.get("offset")
|
||||
search_name = request.args.get("search_name")
|
||||
search_key = request.args.get("search_key")
|
||||
ingredient = request.args.get("ingredient", type=bool)
|
||||
receipt = request.args.get("receipt", type=bool)
|
||||
try:
|
||||
if limit is not None:
|
||||
limit = int(limit)
|
||||
if offset is not None:
|
||||
offset = int(offset)
|
||||
if ingredient is not None:
|
||||
ingredient = bool(ingredient)
|
||||
if receipt is not None:
|
||||
receipt = bool(receipt)
|
||||
except ValueError:
|
||||
raise BadRequest
|
||||
drinks, count = pricelist_controller.get_drinks(
|
||||
public=public,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
search_name=search_name,
|
||||
search_key=search_key,
|
||||
ingredient=ingredient,
|
||||
receipt=receipt,
|
||||
)
|
||||
mop = drinks.copy()
|
||||
logger.debug(f"GET drink {drinks}, {count}")
|
||||
# return jsonify({"drinks": drinks, "count": count})
|
||||
return jsonify({"drinks": drinks, "count": count})
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/list", methods=["GET"])
|
||||
def get_pricelist():
|
||||
"""Get Priclist
|
||||
Route: ``/pricelist/list`` | Method: ``GET``
|
||||
|
||||
Returns:
|
||||
JSON encoded list of DrinkPrices or HTTP-KeyError
|
||||
"""
|
||||
public = True
|
||||
try:
|
||||
extract_session()
|
||||
public = False
|
||||
except Unauthorized:
|
||||
public = True
|
||||
|
||||
limit = request.args.get("limit")
|
||||
offset = request.args.get("offset")
|
||||
search_name = request.args.get("search_name")
|
||||
search_key = request.args.get("search_key")
|
||||
ingredient = request.args.get("ingredient", type=bool)
|
||||
receipt = request.args.get("receipt", type=bool)
|
||||
descending = request.args.get("descending", type=bool)
|
||||
sortBy = request.args.get("sortBy")
|
||||
try:
|
||||
if limit is not None:
|
||||
limit = int(limit)
|
||||
if offset is not None:
|
||||
offset = int(offset)
|
||||
if ingredient is not None:
|
||||
ingredient = bool(ingredient)
|
||||
if receipt is not None:
|
||||
receipt = bool(receipt)
|
||||
if descending is not None:
|
||||
descending = bool(descending)
|
||||
except ValueError:
|
||||
raise BadRequest
|
||||
pricelist, count = pricelist_controller.get_pricelist(
|
||||
public=public,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
search_name=search_name,
|
||||
search_key=search_key,
|
||||
descending=descending,
|
||||
sortBy=sortBy,
|
||||
)
|
||||
logger.debug(f"GET pricelist {pricelist}, {count}")
|
||||
return jsonify({"pricelist": pricelist, "count": count})
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
|
||||
def search_drinks(name):
|
||||
return jsonify(pricelist_controller.get_drinks(name))
|
||||
"""Search Drink
|
||||
|
||||
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"])
|
||||
@login_required(permission=permissions.CREATE)
|
||||
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()
|
||||
return jsonify(pricelist_controller.set_drink(data))
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["PUT"])
|
||||
def update_drink(identifier):
|
||||
@login_required(permission=permissions.EDIT)
|
||||
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()
|
||||
logger.debug(f"update drink {data}")
|
||||
return jsonify(pricelist_controller.update_drink(identifier, data))
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["DELETE"])
|
||||
def delete_drink(identifier):
|
||||
@login_required(permission=permissions.DELETE)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/prices/<int:identifier>", methods=["DELETE"])
|
||||
def delete_price(identifier):
|
||||
@login_required(permission=permissions.DELETE_PRICE)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/volumes/<int:identifier>", methods=["DELETE"])
|
||||
def delete_volume(identifier):
|
||||
@login_required(permission=permissions.DELETE_VOLUME)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"])
|
||||
def get_extra_ingredients():
|
||||
@login_required()
|
||||
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())
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"])
|
||||
def delete_ingredient(identifier):
|
||||
@login_required(permission=permissions.DELETE_INGREDIENTS_DRINK)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"])
|
||||
def set_extra_ingredient():
|
||||
@login_required(permission=permissions.EDIT_INGREDIENTS)
|
||||
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()
|
||||
return jsonify(pricelist_controller.set_extra_ingredient(data))
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
|
||||
def update_extra_ingredient(identifier):
|
||||
@login_required(permission=permissions.EDIT_INGREDIENTS)
|
||||
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()
|
||||
return jsonify(pricelist_controller.update_extra_ingredient(identifier, data))
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
|
||||
def delete_extra_ingredient(identifier):
|
||||
@login_required(permission=permissions.DELETE_INGREDIENTS)
|
||||
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)
|
||||
return no_content()
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST", "GET"])
|
||||
def pricelist_settings_min_prices():
|
||||
if request.method == "GET":
|
||||
# TODO: Handle if no prices are set!
|
||||
return jsonify(PriceListPlugin.plugin.get_setting("min_prices"))
|
||||
else:
|
||||
data = request.get_json()
|
||||
if not isinstance(data, list) or not all(isinstance(n, int) for n in data):
|
||||
raise BadRequest
|
||||
data.sort()
|
||||
PriceListPlugin.plugin.set_setting("min_prices", data)
|
||||
return no_content()
|
||||
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["GET"])
|
||||
@login_required()
|
||||
def get_pricelist_settings_min_prices(current_session):
|
||||
"""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!
|
||||
try:
|
||||
min_prices = PriceListPlugin.plugin.get_setting("min_prices")
|
||||
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()
|
||||
if not isinstance(data, list) or not all(isinstance(n, int) for n in data):
|
||||
raise BadRequest
|
||||
data.sort()
|
||||
PriceListPlugin.plugin.set_setting("min_prices", data)
|
||||
return no_content()
|
||||
|
||||
|
||||
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
|
||||
@login_required()
|
||||
def get_columns(userid, current_session: Session):
|
||||
def get_columns(userid, current_session):
|
||||
"""Get pricecalc_columns of an user
|
||||
|
||||
Route: ``/users/<userid>/pricelist/pricecac_columns`` | Method: ``GET`` or ``PUT``
|
||||
|
@ -219,3 +644,113 @@ def get_columns(userid, current_session: Session):
|
|||
user.set_attribute("pricecalc_columns", data)
|
||||
userController.persist()
|
||||
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
|
||||
|
|
|
@ -1,20 +1,21 @@
|
|||
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
|
||||
|
||||
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
|
||||
|
||||
drink_tag_association = db.Table(
|
||||
"drink_x_tag",
|
||||
db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")),
|
||||
db.Column("tag_id", db.Integer, db.ForeignKey("drink_tag.id")),
|
||||
db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
|
||||
db.Column("tag_id", Serial, db.ForeignKey("drink_tag.id")),
|
||||
)
|
||||
|
||||
drink_type_association = db.Table(
|
||||
"drink_x_type",
|
||||
db.Column("drink_id", db.Integer, db.ForeignKey("drink.id")),
|
||||
db.Column("type_id", db.Integer, db.ForeignKey("drink_type.id")),
|
||||
db.Column("drink_id", Serial, db.ForeignKey("drink.id")),
|
||||
db.Column("type_id", Serial, db.ForeignKey("drink_type.id")),
|
||||
)
|
||||
|
||||
|
||||
|
@ -24,8 +25,9 @@ class Tag(db.Model, ModelSerializeMixin):
|
|||
"""
|
||||
|
||||
__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)
|
||||
color: str = db.Column(db.String(7), nullable=False)
|
||||
|
||||
|
||||
class DrinkType(db.Model, ModelSerializeMixin):
|
||||
|
@ -34,7 +36,7 @@ class DrinkType(db.Model, ModelSerializeMixin):
|
|||
"""
|
||||
|
||||
__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)
|
||||
|
||||
|
||||
|
@ -44,21 +46,25 @@ class DrinkPrice(db.Model, ModelSerializeMixin):
|
|||
"""
|
||||
|
||||
__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))
|
||||
volume_id_ = db.Column("volume_id", db.Integer, db.ForeignKey("drink_price_volume.id"))
|
||||
volume = db.relationship("DrinkPriceVolume", back_populates="prices")
|
||||
volume_id_ = db.Column("volume_id", Serial, db.ForeignKey("drink_price_volume.id"))
|
||||
volume: "DrinkPriceVolume" = None
|
||||
_volume: "DrinkPriceVolume" = db.relationship("DrinkPriceVolume", back_populates="_prices", join_depth=1)
|
||||
public: bool = db.Column(db.Boolean, default=True)
|
||||
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):
|
||||
"""
|
||||
ExtraIngredient
|
||||
"""
|
||||
|
||||
__tablename__ = "extra_ingredient"
|
||||
id: int = db.Column("id", db.Integer, primary_key=True)
|
||||
__tablename__ = "drink_extra_ingredient"
|
||||
id: int = db.Column("id", Serial, primary_key=True)
|
||||
name: str = db.Column(db.String(30), unique=True, nullable=False)
|
||||
price: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
|
||||
|
||||
|
@ -69,19 +75,20 @@ class DrinkIngredient(db.Model, ModelSerializeMixin):
|
|||
"""
|
||||
|
||||
__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)
|
||||
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id"))
|
||||
# drink_ingredient: Drink = db.relationship("Drink")
|
||||
# price: float = 0
|
||||
ingredient_id: int = db.Column(Serial, db.ForeignKey("drink.id"))
|
||||
cost_per_volume: float
|
||||
name: str
|
||||
_drink_ingredient: Drink = db.relationship("Drink")
|
||||
|
||||
@property
|
||||
def cost_per_volume(self):
|
||||
return self._drink_ingredient.cost_per_volume if self._drink_ingredient else None
|
||||
|
||||
# @property
|
||||
# def price(self):
|
||||
# try:
|
||||
# return self.drink_ingredient.cost_price_pro_volume * self.volume
|
||||
# except AttributeError:
|
||||
# pass
|
||||
@property
|
||||
def name(self):
|
||||
return self._drink_ingredient.name if self._drink_ingredient else None
|
||||
|
||||
|
||||
class Ingredient(db.Model, ModelSerializeMixin):
|
||||
|
@ -89,14 +96,14 @@ class Ingredient(db.Model, ModelSerializeMixin):
|
|||
Ingredient Associationtable
|
||||
"""
|
||||
|
||||
__tablename__ = "ingredient_association"
|
||||
id: int = db.Column("id", db.Integer, primary_key=True)
|
||||
volume_id = db.Column(db.Integer, db.ForeignKey("drink_price_volume.id"))
|
||||
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient)
|
||||
__tablename__ = "drink_ingredient_association"
|
||||
id: int = db.Column("id", Serial, primary_key=True)
|
||||
volume_id = db.Column(Serial, db.ForeignKey("drink_price_volume.id"))
|
||||
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient, cascade="all,delete")
|
||||
extra_ingredient: Optional[ExtraIngredient] = db.relationship(ExtraIngredient)
|
||||
|
||||
_drink_ingredient_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id"))
|
||||
_extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id"))
|
||||
_drink_ingredient_id = db.Column(Serial, db.ForeignKey("drink_ingredient.id"))
|
||||
_extra_ingredient_id = db.Column(Serial, db.ForeignKey("drink_extra_ingredient.id"))
|
||||
|
||||
|
||||
class MinPrices(ModelSerializeMixin):
|
||||
|
@ -114,14 +121,25 @@ class DrinkPriceVolume(db.Model, ModelSerializeMixin):
|
|||
"""
|
||||
|
||||
__tablename__ = "drink_price_volume"
|
||||
id: int = db.Column("id", db.Integer, primary_key=True)
|
||||
drink_id = db.Column(db.Integer, db.ForeignKey("drink.id"))
|
||||
id: int = db.Column("id", Serial, primary_key=True)
|
||||
drink_id = db.Column(Serial, db.ForeignKey("drink.id"))
|
||||
drink: "Drink" = None
|
||||
_drink: "Drink" = db.relationship("Drink", back_populates="_volumes")
|
||||
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
|
||||
min_prices: list[MinPrices] = []
|
||||
# ingredients: list[Ingredient] = []
|
||||
prices: list[DrinkPrice] = []
|
||||
_prices: list[DrinkPrice] = db.relationship(
|
||||
DrinkPrice, back_populates="_volume", cascade="all,delete,delete-orphan"
|
||||
)
|
||||
ingredients: list[Ingredient] = db.relationship(
|
||||
"Ingredient",
|
||||
foreign_keys=Ingredient.volume_id,
|
||||
cascade="all,delete,delete-orphan",
|
||||
)
|
||||
|
||||
prices: list[DrinkPrice] = db.relationship(DrinkPrice, back_populates="volume", cascade="all,delete,delete-orphan")
|
||||
ingredients: list[Ingredient] = db.relationship("Ingredient", foreign_keys=Ingredient.volume_id)
|
||||
def __repr__(self):
|
||||
return f"DrinkPriceVolume({self.id},{self.drink_id},{self.volume},{self.prices})"
|
||||
|
||||
|
||||
class Drink(db.Model, ModelSerializeMixin):
|
||||
|
@ -130,25 +148,32 @@ class Drink(db.Model, ModelSerializeMixin):
|
|||
"""
|
||||
|
||||
__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))
|
||||
package_size: Optional[int] = db.Column(db.Integer)
|
||||
name: str = db.Column(db.String(60), nullable=False)
|
||||
volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False))
|
||||
cost_per_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
|
||||
cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
|
||||
has_image: bool = False
|
||||
|
||||
uuid = db.Column(db.String(36))
|
||||
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")
|
||||
type: Optional[DrinkType] = db.relationship("DrinkType", foreign_keys=[_type_id])
|
||||
volumes: list[DrinkPriceVolume] = db.relationship(DrinkPriceVolume)
|
||||
volumes: list[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})"
|
||||
|
||||
class _Picture:
|
||||
"""Wrapper class for pictures binaries"""
|
||||
|
||||
mimetype = ""
|
||||
binary = bytearray()
|
||||
@property
|
||||
def has_image(self):
|
||||
return self.image_ is not None
|
||||
|
|
|
@ -10,6 +10,18 @@ DELETE = "drink_delete"
|
|||
CREATE_TAG = "drink_tag_create"
|
||||
"""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"
|
||||
|
||||
DELETE_TAG = "drink_tag_delete"
|
||||
|
@ -20,4 +32,6 @@ EDIT_TYPE = "drink_type_edit"
|
|||
|
||||
DELETE_TYPE = "drink_type_delete"
|
||||
|
||||
EDIT_MIN_PRICES = "edit_min_prices"
|
||||
|
||||
permissions = [value for key, value in globals().items() if not key.startswith("_")]
|
||||
|
|
|
@ -1,13 +1,23 @@
|
|||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from uuid import uuid4
|
||||
|
||||
from flaschengeist import logger
|
||||
from flaschengeist.config import config
|
||||
from flaschengeist.database import db
|
||||
from flaschengeist.utils.picture import save_picture, get_picture
|
||||
from flaschengeist.utils.decorators import extract_session
|
||||
|
||||
from .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient
|
||||
from .models import (
|
||||
Drink,
|
||||
DrinkPrice,
|
||||
Ingredient,
|
||||
Tag,
|
||||
DrinkType,
|
||||
DrinkPriceVolume,
|
||||
DrinkIngredient,
|
||||
ExtraIngredient,
|
||||
)
|
||||
from .permissions import EDIT_VOLUME, EDIT_PRICE, EDIT_INGREDIENTS_DRINK
|
||||
|
||||
import flaschengeist.controller.imageController as image_controller
|
||||
|
||||
|
||||
def update():
|
||||
|
@ -31,9 +41,13 @@ def get_tag(identifier):
|
|||
return ret
|
||||
|
||||
|
||||
def create_tag(name):
|
||||
def create_tag(data):
|
||||
try:
|
||||
tag = Tag(name=name)
|
||||
if "id" in data:
|
||||
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)
|
||||
update()
|
||||
return tag
|
||||
|
@ -41,9 +55,12 @@ def create_tag(name):
|
|||
raise BadRequest("Name already exists")
|
||||
|
||||
|
||||
def rename_tag(identifier, new_name):
|
||||
def update_tag(identifier, data):
|
||||
tag = get_tag(identifier)
|
||||
tag.name = new_name
|
||||
allowed_keys = Tag().serialize().keys()
|
||||
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:
|
||||
update()
|
||||
except IntegrityError:
|
||||
|
@ -105,13 +122,164 @@ def delete_drink_type(identifier):
|
|||
raise BadRequest("DrinkType still in use")
|
||||
|
||||
|
||||
def get_drinks(name=None):
|
||||
def _create_public_drink(drink):
|
||||
_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:
|
||||
return Drink.query.filter(Drink.name.contains(name)).all()
|
||||
return Drink.query.all()
|
||||
query = Drink.query.filter(Drink.name.contains(name))
|
||||
else:
|
||||
query = Drink.query
|
||||
if ingredient:
|
||||
query = query.filter(Drink.cost_per_volume >= 0)
|
||||
if receipt:
|
||||
query = query.filter(Drink._volumes.any(DrinkPriceVolume.ingredients != None))
|
||||
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_drink(identifier):
|
||||
def get_pricelist(
|
||||
public=False,
|
||||
limit=None,
|
||||
offset=None,
|
||||
search_name=None,
|
||||
search_key=None,
|
||||
sortBy=None,
|
||||
descending=False,
|
||||
):
|
||||
count = None
|
||||
query = DrinkPrice.query
|
||||
if public:
|
||||
query = query.filter(DrinkPrice.public)
|
||||
if search_name:
|
||||
if search_key == "name":
|
||||
query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name))))
|
||||
if search_key == "type":
|
||||
query = query.filter(
|
||||
DrinkPrice._volume.has(
|
||||
DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name)))
|
||||
)
|
||||
)
|
||||
if search_key == "tags":
|
||||
query = query.filter(
|
||||
DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.conaitns(search_name))))
|
||||
)
|
||||
if search_key == "volume":
|
||||
query = query.filter(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name)))
|
||||
if search_key == "price":
|
||||
query = query.filter(DrinkPrice.price == float(search_name))
|
||||
if search_key == "description":
|
||||
query = query.filter(DrinkPrice.description.contains(search_name))
|
||||
else:
|
||||
try:
|
||||
search_name = float(search_name)
|
||||
query = query.filter(
|
||||
(DrinkPrice._volume.has(DrinkPriceVolume.volume == float(search_name)))
|
||||
| (DrinkPrice.price == float(search_name))
|
||||
)
|
||||
except:
|
||||
query = query.filter(
|
||||
(DrinkPrice._volume.has(DrinkPriceVolume._drink.has(Drink.name.contains(search_name))))
|
||||
| (
|
||||
DrinkPrice._volume.has(
|
||||
DrinkPriceVolume._drink.has(Drink.type.has(DrinkType.name.contains(search_name)))
|
||||
)
|
||||
)
|
||||
| (
|
||||
DrinkPrice._volume.has(
|
||||
DrinkPriceVolume._drink.has(Drink.tags.any(Tag.name.contains(search_name)))
|
||||
)
|
||||
)
|
||||
| (DrinkPrice.description.contains(search_name))
|
||||
)
|
||||
if sortBy == "type":
|
||||
query = (
|
||||
query.join(DrinkPrice._volume)
|
||||
.join(DrinkPriceVolume._drink)
|
||||
.join(Drink.type)
|
||||
.order_by(DrinkType.name.desc() if descending else DrinkType.name.asc())
|
||||
)
|
||||
elif sortBy == "volume":
|
||||
query = query.join(DrinkPrice._volume).order_by(
|
||||
DrinkPriceVolume.volume.desc() if descending else DrinkPriceVolume.volume.asc()
|
||||
)
|
||||
elif sortBy == "price":
|
||||
query = query.order_by(DrinkPrice.price.desc() if descending else DrinkPrice.price.asc())
|
||||
else:
|
||||
query = (
|
||||
query.join(DrinkPrice._volume)
|
||||
.join(DrinkPriceVolume._drink)
|
||||
.order_by(Drink.name.desc() if descending else Drink.name.asc())
|
||||
)
|
||||
if limit is not None:
|
||||
count = query.count()
|
||||
query = query.limit(limit)
|
||||
if offset is not None:
|
||||
query = query.offset(offset)
|
||||
|
||||
prices = query.all()
|
||||
for price in prices:
|
||||
price._volume.drink = price._volume._drink
|
||||
price.volume = price._volume
|
||||
return prices, count
|
||||
|
||||
|
||||
def get_drink(identifier, public=False):
|
||||
drink = None
|
||||
if isinstance(identifier, int):
|
||||
drink = Drink.query.get(identifier)
|
||||
elif isinstance(identifier, str):
|
||||
|
@ -120,6 +288,11 @@ def get_drink(identifier):
|
|||
raise BadRequest("Invalid identifier type for Drink")
|
||||
if drink is None:
|
||||
raise NotFound
|
||||
if public:
|
||||
return _create_public_drink(drink)
|
||||
for volume in drink._volumes:
|
||||
volume.prices = volume._prices
|
||||
drink.volumes = drink._volumes
|
||||
return drink
|
||||
|
||||
|
||||
|
@ -129,11 +302,17 @@ def set_drink(data):
|
|||
|
||||
def update_drink(identifier, data):
|
||||
try:
|
||||
session = extract_session()
|
||||
if "id" in data:
|
||||
data.pop("id")
|
||||
volumes = data.pop("volumes") if "volumes" in data else None
|
||||
tags = []
|
||||
if "tags" in data:
|
||||
data.pop("tags")
|
||||
_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")
|
||||
if isinstance(drink_type, dict) and "id" in drink_type:
|
||||
drink_type = drink_type["id"]
|
||||
|
@ -144,24 +323,33 @@ def update_drink(identifier, data):
|
|||
else:
|
||||
drink = get_drink(identifier)
|
||||
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)
|
||||
|
||||
if drink_type:
|
||||
drink.type = drink_type
|
||||
if volumes is not None:
|
||||
set_volumes(volumes, drink)
|
||||
if volumes is not None and session.user_.has_permission(EDIT_VOLUME):
|
||||
drink._volumes = []
|
||||
drink._volumes = set_volumes(volumes)
|
||||
if len(tags) > 0:
|
||||
drink.tags = tags
|
||||
db.session.commit()
|
||||
for volume in drink._volumes:
|
||||
volume.prices = volume._prices
|
||||
drink.volumes = drink._volumes
|
||||
|
||||
return drink
|
||||
except (NotFound, KeyError):
|
||||
raise BadRequest
|
||||
|
||||
|
||||
def set_volumes(volumes, drink):
|
||||
def set_volumes(volumes):
|
||||
retVal = []
|
||||
if not isinstance(volumes, list):
|
||||
raise BadRequest
|
||||
for volume in volumes:
|
||||
drink.volumes.append(set_volume(volume))
|
||||
retVal.append(set_volume(volume))
|
||||
return retVal
|
||||
|
||||
|
||||
def delete_drink(identifier):
|
||||
|
@ -181,6 +369,7 @@ def get_volumes(drink_id=None):
|
|||
|
||||
|
||||
def set_volume(data):
|
||||
session = extract_session()
|
||||
allowed_keys = DrinkPriceVolume().serialize().keys()
|
||||
values = {key: value for key, value in data.items() if key in allowed_keys}
|
||||
prices = None
|
||||
|
@ -189,20 +378,13 @@ def set_volume(data):
|
|||
prices = values.pop("prices")
|
||||
if "ingredients" in values:
|
||||
ingredients = values.pop("ingredients")
|
||||
vol_id = values.pop("id", None)
|
||||
if vol_id < 0:
|
||||
volume = DrinkPriceVolume(**values)
|
||||
db.session.add(volume)
|
||||
else:
|
||||
volume = get_volume(vol_id)
|
||||
if not volume:
|
||||
raise NotFound
|
||||
for key, value in values.items():
|
||||
setattr(volume, key, value if value != "" else None)
|
||||
values.pop("id", None)
|
||||
volume = DrinkPriceVolume(**values)
|
||||
db.session.add(volume)
|
||||
|
||||
if prices:
|
||||
if prices and session.user_.has_permission(EDIT_PRICE):
|
||||
set_prices(prices, volume)
|
||||
if ingredients:
|
||||
if ingredients and session.user_.has_permission(EDIT_INGREDIENTS_DRINK):
|
||||
set_ingredients(ingredients, volume)
|
||||
return volume
|
||||
|
||||
|
@ -213,7 +395,7 @@ def set_prices(prices, volume):
|
|||
for _price in prices:
|
||||
price = set_price(_price)
|
||||
_prices.append(price)
|
||||
volume.prices = _prices
|
||||
volume._prices = _prices
|
||||
|
||||
|
||||
def set_ingredients(ingredients, volume):
|
||||
|
@ -244,18 +426,13 @@ def get_prices(volume_id=None):
|
|||
|
||||
|
||||
def set_price(data):
|
||||
allowed_keys = DrinkPrice().serialize().keys()
|
||||
allowed_keys = list(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}
|
||||
price_id = values.pop("id", -1)
|
||||
if price_id < 0:
|
||||
price = DrinkPrice(**values)
|
||||
db.session.add(price)
|
||||
else:
|
||||
price = get_price(price_id)
|
||||
if not price:
|
||||
raise NotFound
|
||||
for key, value in values.items():
|
||||
setattr(price, key, value)
|
||||
values.pop("id", -1)
|
||||
price = DrinkPrice(**values)
|
||||
db.session.add(price)
|
||||
|
||||
return price
|
||||
|
||||
|
@ -269,16 +446,13 @@ def delete_price(identifier):
|
|||
def set_drink_ingredient(data):
|
||||
allowed_keys = DrinkIngredient().serialize().keys()
|
||||
values = {key: value for key, value in data.items() if key in allowed_keys}
|
||||
ingredient_id = values.pop("id", -1)
|
||||
if ingredient_id < 0:
|
||||
drink_ingredient = DrinkIngredient(**values)
|
||||
db.session.add(drink_ingredient)
|
||||
else:
|
||||
drink_ingredient = DrinkIngredient.query.get(ingredient_id)
|
||||
if not drink_ingredient:
|
||||
raise NotFound
|
||||
for key, value in values.items():
|
||||
setattr(drink_ingredient, key, value if value != "" else None)
|
||||
if "cost_per_volume" in values:
|
||||
values.pop("cost_per_volume")
|
||||
if "name" in values:
|
||||
values.pop("name")
|
||||
values.pop("id", -1)
|
||||
drink_ingredient = DrinkIngredient(**values)
|
||||
db.session.add(drink_ingredient)
|
||||
return drink_ingredient
|
||||
|
||||
|
||||
|
@ -293,14 +467,9 @@ def set_ingredient(data):
|
|||
drink_ingredient_value = data.pop("drink_ingredient")
|
||||
if "extra_ingredient" in data:
|
||||
extra_ingredient_value = data.pop("extra_ingredient")
|
||||
ingredient_id = data.pop("id", -1)
|
||||
if ingredient_id < 0:
|
||||
ingredient = Ingredient(**data)
|
||||
db.session.add(ingredient)
|
||||
else:
|
||||
ingredient = get_ingredient(ingredient_id)
|
||||
if not ingredient:
|
||||
raise NotFound
|
||||
data.pop("id", -1)
|
||||
ingredient = Ingredient(**data)
|
||||
db.session.add(ingredient)
|
||||
if drink_ingredient_value:
|
||||
ingredient.drink_ingredient = set_drink_ingredient(drink_ingredient_value)
|
||||
if extra_ingredient_value:
|
||||
|
@ -356,17 +525,16 @@ def delete_extra_ingredient(identifier):
|
|||
|
||||
|
||||
def save_drink_picture(identifier, file):
|
||||
drink = get_drink(identifier)
|
||||
if not drink.uuid:
|
||||
drink.uuid = str(uuid4())
|
||||
db.session.commit()
|
||||
path = config["pricelist"]["path"]
|
||||
save_picture(file, f"{path}/{drink.uuid}")
|
||||
drink = delete_drink_picture(identifier)
|
||||
drink.image_ = image_controller.upload_image(file)
|
||||
db.session.commit()
|
||||
return drink
|
||||
|
||||
|
||||
def get_drink_picture(identifier, size=None):
|
||||
def delete_drink_picture(identifier):
|
||||
drink = get_drink(identifier)
|
||||
if not drink.uuid:
|
||||
raise BadRequest
|
||||
path = config["pricelist"]["path"]
|
||||
return get_picture(f"{path}/{drink.uuid}")
|
||||
if drink.image_:
|
||||
db.session.delete(drink.image_)
|
||||
drink.image_ = None
|
||||
db.session.commit()
|
||||
return drink
|
||||
|
|
|
@ -75,7 +75,7 @@ def list_users(current_session):
|
|||
|
||||
@UsersPlugin.blueprint.route("/users/<userid>", methods=["GET"])
|
||||
@login_required()
|
||||
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
|
||||
@headers({"Cache-Control": "private, must-revalidate, max-age=300"})
|
||||
def get_user(userid, current_session):
|
||||
"""Retrieve user by userid
|
||||
|
||||
|
@ -144,6 +144,16 @@ def set_avatar(userid, current_session):
|
|||
raise BadRequest
|
||||
|
||||
|
||||
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["DELETE"])
|
||||
@login_required()
|
||||
def delete_avatar(userid, current_session):
|
||||
user = userController.get_user(userid)
|
||||
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.EDIT):
|
||||
raise Forbidden
|
||||
userController.delete_avatar(user)
|
||||
return "", NO_CONTENT
|
||||
|
||||
|
||||
@UsersPlugin.blueprint.route("/users/<userid>", methods=["DELETE"])
|
||||
@login_required(permission=permissions.DELETE)
|
||||
def delete_user(userid, current_session):
|
||||
|
@ -231,3 +241,21 @@ def notifications(current_session):
|
|||
def remove_notifications(nid, current_session):
|
||||
userController.delete_notification(nid, current_session.user_)
|
||||
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()
|
||||
|
|
|
@ -2,6 +2,24 @@ from http.client import NO_CONTENT, CREATED
|
|||
|
||||
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():
|
||||
return make_response(jsonify(""), NO_CONTENT)
|
||||
|
|
|
@ -1,38 +0,0 @@
|
|||
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
114
readme.md
|
@ -1,9 +1,16 @@
|
|||
# Flaschengeist
|
||||
This is the backend of the Flaschengeist.
|
||||
|
||||
## Installation
|
||||
### Requirements
|
||||
- mysql or mariadb
|
||||
- python 3.6+
|
||||
- `mysql` or `mariadb`
|
||||
- maybe `libmariadb` development files[1]
|
||||
- python 3.7+
|
||||
|
||||
[1] By default Flaschengeist uses mysql as database backend, if you are on Windows Flaschengeist uses `PyMySQL`, but on
|
||||
Linux / Mac the faster `mysqlclient` is used, if it is not already installed installing from pypi requires the
|
||||
development files for `libmariadb` to be present on your system.
|
||||
|
||||
### Install python files
|
||||
pip3 install --user .
|
||||
or with ldap support
|
||||
|
@ -30,9 +37,21 @@ Configuration is done within the a `flaschengeist.toml`file, you can copy the on
|
|||
1. `~/.config/`
|
||||
2. A custom path and set environment variable `FLASCHENGEIST_CONF`
|
||||
|
||||
Change at least the database parameters!
|
||||
Uncomment and change at least all the database parameters!
|
||||
|
||||
### Database installation
|
||||
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
|
||||
|
@ -41,6 +60,8 @@ or with debug messages:
|
|||
|
||||
run_flaschengeist run --debug
|
||||
|
||||
This will run the backend on http://localhost:5000
|
||||
|
||||
## Tests
|
||||
$ pip install '.[test]'
|
||||
$ pytest
|
||||
|
@ -53,89 +74,4 @@ Or with html output (open `htmlcov/index.html` in a browser):
|
|||
$ coverage html
|
||||
|
||||
## 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"
|
||||
]
|
||||
},
|
||||
)
|
||||
Please refer to our [development wiki](https://flaschengeist.dev/Flaschengeist/flaschengeist/wiki/Development).
|
|
@ -17,7 +17,7 @@ class PrefixMiddleware(object):
|
|||
def __call__(self, environ, start_response):
|
||||
|
||||
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
|
||||
return self.app(environ, start_response)
|
||||
else:
|
||||
|
@ -83,19 +83,19 @@ class InterfaceGenerator:
|
|||
import typing
|
||||
|
||||
if (
|
||||
inspect.ismodule(module[1])
|
||||
and module[1].__name__.startswith(self.basename)
|
||||
and module[1].__name__ not in self.known
|
||||
inspect.ismodule(module[1])
|
||||
and module[1].__name__.startswith(self.basename)
|
||||
and module[1].__name__ not in self.known
|
||||
):
|
||||
self.known.append(module[1].__name__)
|
||||
for cls in inspect.getmembers(module[1], lambda x: inspect.isclass(x) or inspect.ismodule(x)):
|
||||
self.walker(cls)
|
||||
elif (
|
||||
inspect.isclass(module[1])
|
||||
and module[1].__module__.startswith(self.basename)
|
||||
and module[0] not in self.classes
|
||||
and not module[0].startswith("_")
|
||||
and hasattr(module[1], "__annotations__")
|
||||
inspect.isclass(module[1])
|
||||
and module[1].__module__.startswith(self.basename)
|
||||
and module[0] not in self.classes
|
||||
and not module[0].startswith("_")
|
||||
and hasattr(module[1], "__annotations__")
|
||||
):
|
||||
self.this_type = module[0]
|
||||
print("\n\n" + module[0] + "\n")
|
||||
|
@ -156,15 +156,39 @@ def export(arguments):
|
|||
app = create_app()
|
||||
with app.app_context():
|
||||
gen = InterfaceGenerator(arguments.namespace, arguments.file)
|
||||
gen.run(models)
|
||||
if arguments.plugins:
|
||||
if not arguments.no_core:
|
||||
gen.run(models)
|
||||
if arguments.plugins is not None:
|
||||
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
|
||||
plg = entry_point.load()
|
||||
if hasattr(plg, "models") and plg.models is not None:
|
||||
gen.run(plg.models)
|
||||
if len(arguments.plugins) == 0 or entry_point.name in arguments.plugins:
|
||||
plg = entry_point.load()
|
||||
if hasattr(plg, "models") and plg.models is not None:
|
||||
gen.run(plg.models)
|
||||
gen.write()
|
||||
|
||||
|
||||
def ldap_sync(arguments):
|
||||
from flaschengeist.app import create_app
|
||||
from flaschengeist.controller import userController
|
||||
from flaschengeist.plugins.auth_ldap import AuthLDAP
|
||||
from ldap3 import SUBTREE
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
auth_ldap: AuthLDAP = app.config.get("FG_PLUGINS").get("auth_ldap")
|
||||
if auth_ldap:
|
||||
conn = auth_ldap.ldap.connection
|
||||
if not conn:
|
||||
conn = auth_ldap.ldap.connect(auth_ldap.root_dn, auth_ldap.root_secret)
|
||||
conn.search(auth_ldap.search_dn, "(uid=*)", SUBTREE, attributes=["uid", "givenName", "sn", "mail"])
|
||||
ldap_users_response = conn.response
|
||||
for ldap_user in ldap_users_response:
|
||||
uid = ldap_user["attributes"]["uid"][0]
|
||||
userController.find_user(uid)
|
||||
exit()
|
||||
raise Exception("auth_ldap not found")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# create the top-level parser
|
||||
parser = argparse.ArgumentParser()
|
||||
|
@ -183,7 +207,15 @@ if __name__ == "__main__":
|
|||
parser_export.set_defaults(func=export)
|
||||
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("--plugins", help="Also export plugins", action="store_true")
|
||||
parser_export.add_argument(
|
||||
"--no-core",
|
||||
help="Do not export core declarations (only useful in conjunction with --plugins)",
|
||||
action="store_true",
|
||||
)
|
||||
parser_export.add_argument("--plugins", help="Also export plugins (none means all)", nargs="*")
|
||||
|
||||
parser_ldap_sync = subparsers.add_parser("ldap_sync", help="synch ldap-users with database")
|
||||
parser_ldap_sync.set_defaults(func=ldap_sync)
|
||||
|
||||
args = parser.parse_args()
|
||||
args.func(args)
|
||||
|
|
24
setup.py
24
setup.py
|
@ -20,7 +20,16 @@ class DocsCommand(Command):
|
|||
|
||||
def run(self):
|
||||
"""Run command."""
|
||||
command = ["python", "-m", "pdoc", "--skip-errors", "--html", "--output-dir", self.output, "flaschengeist"]
|
||||
command = [
|
||||
"python",
|
||||
"-m",
|
||||
"pdoc",
|
||||
"--skip-errors",
|
||||
"--html",
|
||||
"--output-dir",
|
||||
self.output,
|
||||
"flaschengeist",
|
||||
]
|
||||
self.announce(
|
||||
"Running command: %s" % str(command),
|
||||
)
|
||||
|
@ -33,15 +42,20 @@ setup(
|
|||
scripts=["run_flaschengeist"],
|
||||
python_requires=">=3.7",
|
||||
install_requires=[
|
||||
"Flask >= 1.1",
|
||||
"Flask >= 2.0",
|
||||
"toml",
|
||||
"sqlalchemy>=1.4",
|
||||
"sqlalchemy>=1.4.26",
|
||||
"flask_sqlalchemy>=2.5",
|
||||
"flask_cors",
|
||||
"Pillow>=8.4.0",
|
||||
"werkzeug",
|
||||
mysql_driver,
|
||||
],
|
||||
extras_require={"ldap": ["flask_ldapconn", "ldap3"], "pricelist": ["pillow"], "test": ["pytest", "coverage"]},
|
||||
extras_require={
|
||||
"ldap": ["flask_ldapconn", "ldap3"],
|
||||
"argon": ["argon2-cffi"],
|
||||
"test": ["pytest", "coverage"],
|
||||
},
|
||||
entry_points={
|
||||
"flaschengeist.plugin": [
|
||||
# Authentication providers
|
||||
|
@ -54,7 +68,7 @@ setup(
|
|||
"balance = flaschengeist.plugins.balance:BalancePlugin",
|
||||
"events = flaschengeist.plugins.events:EventPlugin",
|
||||
"mail = flaschengeist.plugins.message_mail:MailMessagePlugin",
|
||||
"pricelist = flaschengeist.plugins.pricelist:PriceListPlugin [pricelist]",
|
||||
"pricelist = flaschengeist.plugins.pricelist:PriceListPlugin",
|
||||
],
|
||||
},
|
||||
cmdclass={
|
||||
|
|
|
@ -22,7 +22,13 @@ with open(os.path.join(os.path.dirname(__file__), "data.sql"), "r") as f:
|
|||
@pytest.fixture
|
||||
def app():
|
||||
db_fd, db_path = tempfile.mkstemp()
|
||||
app = create_app({"TESTING": True, "DATABASE": {"file_path": f"/{db_path}"}, "LOGGING": {"level": "DEBUG"}})
|
||||
app = create_app(
|
||||
{
|
||||
"TESTING": True,
|
||||
"DATABASE": {"file_path": f"/{db_path}"},
|
||||
"LOGGING": {"level": "DEBUG"},
|
||||
}
|
||||
)
|
||||
with app.app_context():
|
||||
install_all()
|
||||
engine = database.db.engine
|
||||
|
|
Loading…
Reference in New Issue