Merge remote-tracking branch 'origin/develop' into feature/pricelist

This commit is contained in:
Tim Gröger 2021-03-29 12:50:04 +02:00
commit 3a4e90f50e
26 changed files with 1103 additions and 965 deletions

View File

@ -45,9 +45,10 @@ def __load_plugins(app):
try:
logger.info(f"Load plugin {entry_point.name}")
plugin = entry_point.load()
setattr(plugin, "_plugin_name", entry_point.name)
if not hasattr(plugin, "name"):
setattr(plugin, "name", entry_point.name)
plugin = plugin(config[entry_point.name])
if plugin.blueprint:
if hasattr(plugin, "blueprint") and plugin.blueprint is not None:
app.register_blueprint(plugin.blueprint)
except:
logger.error(

View File

@ -6,6 +6,7 @@ from werkzeug.exceptions import NotFound, BadRequest, Forbidden
from flaschengeist import logger
from flaschengeist.config import config
from flaschengeist.database import db
from flaschengeist.models.notification import Notification
from flaschengeist.utils.hook import Hook
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.models.user import User, Role, _PasswordReset
@ -210,3 +211,15 @@ def persist(user=None):
if user:
db.session.add(user)
db.session.commit()
def get_notifications(user, start=None):
query = Notification.query.filter(Notification.user_id_ == user.id_)
if start is not None:
query = query.filter(Notification.time > start)
return query.order_by(Notification.time).all()
def delete_notification(nid, user):
Notification.query.filter(Notification.id == nid).filter(Notification.user_ == user).delete()
db.session.commit()

View File

@ -1,6 +1,8 @@
import sys
import datetime
from sqlalchemy import BigInteger
from sqlalchemy.dialects import mysql
from sqlalchemy.types import DateTime, TypeDecorator
@ -39,6 +41,12 @@ class ModelSerializeMixin:
return d
class Serial(TypeDecorator):
"""Same as MariaDB Serial used for IDs"""
impl = BigInteger().with_variant(mysql.BIGINT(unsigned=True), "mysql")
class UtcDateTime(TypeDecorator):
"""Almost equivalent to `sqlalchemy.types.DateTime` with
``timezone=True`` option, but it differs from that by:

View File

@ -0,0 +1,19 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from datetime import datetime
from typing import Any
from . import Serial, UtcDateTime, ModelSerializeMixin
from ..database import db
from .user import User
class Notification(db.Model, ModelSerializeMixin):
__tablename__ = "notification"
id: int = db.Column("id", Serial, primary_key=True)
plugin: str = db.Column(db.String(30), nullable=False)
text: str = db.Column(db.Text)
data: Any = db.Column(db.PickleType(protocol=4))
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
user_id_: int = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False)
user_: User = db.relationship("User")

View File

@ -2,7 +2,7 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3
from datetime import datetime, timedelta, timezone
from . import ModelSerializeMixin, UtcDateTime
from . import ModelSerializeMixin, UtcDateTime, Serial
from .user import User
from flaschengeist.database import db
from secrets import compare_digest
@ -26,8 +26,8 @@ class Session(db.Model, ModelSerializeMixin):
platform: str = db.Column(db.String(30))
userid: str = ""
_id = db.Column("id", db.Integer, primary_key=True)
_user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.id"))
_id = db.Column("id", Serial, primary_key=True)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"))
user_: User = db.relationship("User", back_populates="sessions_")
@property

View File

@ -1,11 +1,13 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from typing import Any
from . import Serial
from ..database import db
class _PluginSetting(db.Model):
__tablename__ = "plugin_setting"
id = db.Column("id", db.Integer, primary_key=True)
id = db.Column("id", Serial, primary_key=True)
plugin: str = db.Column(db.String(30))
name: str = db.Column(db.String(30), nullable=False)
value: any = db.Column(db.PickleType(protocol=4))
value: Any = db.Column(db.PickleType(protocol=4))

View File

@ -6,19 +6,19 @@ from datetime import date, datetime
from sqlalchemy.orm.collections import attribute_mapped_collection
from ..database import db
from . import ModelSerializeMixin, UtcDateTime
from . import ModelSerializeMixin, UtcDateTime, Serial
association_table = db.Table(
"user_x_role",
db.Column("user_id", db.Integer, db.ForeignKey("user.id")),
db.Column("role_id", db.Integer, db.ForeignKey("role.id")),
db.Column("user_id", Serial, db.ForeignKey("user.id")),
db.Column("role_id", Serial, db.ForeignKey("role.id")),
)
role_permission_association_table = db.Table(
"role_x_permission",
db.Column("role_id", db.Integer, db.ForeignKey("role.id")),
db.Column("permission_id", db.Integer, db.ForeignKey("permission.id")),
db.Column("role_id", Serial, db.ForeignKey("role.id")),
db.Column("permission_id", Serial, db.ForeignKey("permission.id")),
)
@ -26,12 +26,12 @@ class Permission(db.Model, ModelSerializeMixin):
__tablename__ = "permission"
name: str = db.Column(db.String(30), unique=True)
_id = db.Column("id", db.Integer, primary_key=True)
_id = db.Column("id", Serial, primary_key=True)
class Role(db.Model, ModelSerializeMixin):
__tablename__ = "role"
id: int = db.Column(db.Integer, primary_key=True)
id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), unique=True)
permissions: list[Permission] = db.relationship("Permission", secondary=role_permission_association_table)
@ -62,7 +62,7 @@ class User(db.Model, ModelSerializeMixin):
permissions: Optional[list[str]] = None
avatar_url: Optional[str] = ""
id_ = db.Column("id", db.Integer, primary_key=True)
id_ = db.Column("id", Serial, primary_key=True)
roles_: list[Role] = db.relationship("Role", secondary=association_table, cascade="save-update, merge")
sessions_ = db.relationship("Session", back_populates="user_")
@ -101,8 +101,8 @@ class User(db.Model, ModelSerializeMixin):
class _UserAttribute(db.Model, ModelSerializeMixin):
__tablename__ = "user_attribute"
id = db.Column("id", db.Integer, primary_key=True)
user: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), nullable=False)
id = db.Column("id", Serial, primary_key=True)
user: User = db.Column("user", Serial, db.ForeignKey("user.id"), nullable=False)
name: str = db.Column(db.String(30))
value: any = db.Column(db.PickleType(protocol=4))
@ -111,7 +111,7 @@ class _PasswordReset(db.Model):
"""Table containing password reset requests"""
__tablename__ = "password_reset"
_user_id: User = db.Column("user", db.Integer, db.ForeignKey("user.id"), primary_key=True)
_user_id: User = db.Column("user", Serial, db.ForeignKey("user.id"), primary_key=True)
user: User = db.relationship("User", foreign_keys=[_user_id])
token: str = db.Column(db.String(32))
expires: datetime = db.Column(UtcDateTime)

View File

@ -3,6 +3,7 @@ import pkg_resources
from werkzeug.exceptions import MethodNotAllowed, NotFound
from flaschengeist.database import db
from flaschengeist.models.notification import Notification
from flaschengeist.models.user import _Avatar
from flaschengeist.models.setting import _PluginSetting
from flaschengeist.utils.hook import HookBefore, HookAfter
@ -30,15 +31,16 @@ class Plugin:
"""Base class for all Plugins
If your class uses custom models add a static property called ``models``"""
def __init__(self, config=None, blueprint=None, permissions=[]):
blueprint = None # You have to override
permissions = [] # You have to override
name = "plugin" # You have to override
models = None # You have to override
def __init__(self, config=None):
"""Constructor called by create_app
Args:
config: Dict configuration containing the plugin section
blueprint: A flask blueprint containing all plugin routes
permissions: List of permissions of this Plugin
"""
self.blueprint = blueprint
self.permissions = permissions
self.version = pkg_resources.get_distribution(self.__module__.split(".")[0]).version
def install(self):
@ -63,7 +65,7 @@ class Plugin:
"""
try:
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == self._plugin_name)
_PluginSetting.query.filter(_PluginSetting.plugin == self.name)
.filter(_PluginSetting.name == name)
.one()
)
@ -81,14 +83,19 @@ class Plugin:
value: Value to be stored
"""
setting = (
_PluginSetting.query.filter(_PluginSetting.plugin == self._plugin_name)
_PluginSetting.query.filter(_PluginSetting.plugin == self.name)
.filter(_PluginSetting.name == name)
.one_or_none()
)
if setting is not None:
setting.value = value
else:
db.session.add(_PluginSetting(plugin=self._plugin_name, name=name, value=value))
db.session.add(_PluginSetting(plugin=self.name, name=name, value=value))
db.session.commit()
def notify(self, user, text: str, data=None):
n = Notification(text=text, data=data, plugin=self.name, user_=user)
db.session.add(n)
db.session.commit()
def serialize(self):

View File

@ -11,15 +11,13 @@ from flaschengeist.utils.HTTP import no_content, created
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import sessionController, userController
auth_bp = Blueprint("auth", __name__)
class AuthRoutePlugin(Plugin):
def __init__(self, conf):
super().__init__(blueprint=auth_bp)
name = "auth"
blueprint = Blueprint(name, __name__)
@auth_bp.route("/auth", methods=["POST"])
@AuthRoutePlugin.blueprint.route("/auth", methods=["POST"])
def login():
"""Login in an user and create a session
@ -52,7 +50,7 @@ def login():
return created(session)
@auth_bp.route("/auth", methods=["GET"])
@AuthRoutePlugin.blueprint.route("/auth", methods=["GET"])
@login_required()
def get_sessions(current_session, **kwargs):
"""Get all valid sessions of current user
@ -66,7 +64,7 @@ def get_sessions(current_session, **kwargs):
return jsonify(sessions)
@auth_bp.route("/auth/<token>", methods=["DELETE"])
@AuthRoutePlugin.blueprint.route("/auth/<token>", methods=["DELETE"])
@login_required()
def delete_session(token, current_session, **kwargs):
"""Delete a session aka "logout"
@ -88,7 +86,7 @@ def delete_session(token, current_session, **kwargs):
return ""
@auth_bp.route("/auth/<token>", methods=["GET"])
@AuthRoutePlugin.blueprint.route("/auth/<token>", methods=["GET"])
@login_required()
def get_session(token, current_session, **kwargs):
"""Retrieve information about a session
@ -111,7 +109,7 @@ def get_session(token, current_session, **kwargs):
return jsonify(session)
@auth_bp.route("/auth/<token>", methods=["PUT"])
@AuthRoutePlugin.blueprint.route("/auth/<token>", methods=["PUT"])
@login_required()
def set_lifetime(token, current_session, **kwargs):
"""Set lifetime of a session
@ -141,7 +139,7 @@ def set_lifetime(token, current_session, **kwargs):
raise BadRequest
@auth_bp.route("/auth/<token>/user", methods=["GET"])
@AuthRoutePlugin.blueprint.route("/auth/<token>/user", methods=["GET"])
@login_required()
def get_assocd_user(token, current_session, **kwargs):
"""Retrieve user owning a session
@ -164,7 +162,7 @@ def get_assocd_user(token, current_session, **kwargs):
return jsonify(session.user_)
@auth_bp.route("/auth/reset", methods=["POST"])
@AuthRoutePlugin.blueprint.route("/auth/reset", methods=["POST"])
def reset_password():
data = request.get_json()
if "userid" in data:

View File

@ -3,30 +3,25 @@
Extends users plugin with balance functions
"""
from datetime import datetime, timezone
from flaschengeist.utils.HTTP import no_content
from flask import Blueprint, request, jsonify
from werkzeug.exceptions import Forbidden, BadRequest
from werkzeug.local import LocalProxy
from flask import Blueprint, current_app
from flaschengeist import logger
from flaschengeist.utils import HTTP
from flaschengeist.models.session import Session
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import userController
from flaschengeist.plugins import Plugin, before_update_user
from . import balance_controller, permissions, models
balance_bp = Blueprint("balance", __name__)
from . import permissions, models
class BalancePlugin(Plugin):
name = "balance"
blueprint = Blueprint(name, __name__)
permissions = permissions.permissions
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][BalancePlugin.name])
models = models
def __init__(self, config):
super().__init__(blueprint=balance_bp, permissions=permissions.permissions)
super(BalancePlugin, self).__init__(config)
from . import routes, balance_controller
@before_update_user
def set_default_limit(user):
@ -36,277 +31,3 @@ class BalancePlugin(Plugin):
balance_controller.set_limit(user, limit, override=False)
except KeyError:
pass
def install(self):
from flaschengeist.database import db
db.create_all()
def str2bool(string: str):
if string.lower() in ["true", "yes", "1"]:
return True
elif string.lower() in ["false", "no", "0"]:
return False
raise ValueError
@balance_bp.route("/users/<userid>/balance/shortcuts", methods=["GET", "PUT"])
@login_required()
def get_shortcuts(userid, current_session: Session):
"""Get balance shortcuts of an user
Route: ``/users/<userid>/balance/shortcuts`` | 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 float 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("balance_shortcuts", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data):
raise BadRequest
data.sort(reverse=True)
user.set_attribute("balance_shortcuts", data)
userController.persist()
return no_content()
@balance_bp.route("/users/<userid>/balance/limit", methods=["GET"])
@login_required()
def get_limit(userid, current_session: Session):
"""Get limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``GET``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
JSON object containing the limit (or Null if no limit set) or HTTP error
"""
user = userController.get_user(userid)
if (user != current_session.user_ and not current_session.user_.has_permission(permissions.SET_LIMIT)) or (
user == current_session.user_ and not user.has_permission(permissions.SHOW)
):
raise Forbidden
return {"limit": balance_controller.get_limit(user)}
@balance_bp.route("/users/<userid>/balance/limit", methods=["PUT"])
@login_required(permissions.SET_LIMIT)
def set_limit(userid, current_session: Session):
"""Set the limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``PUT``
POST-data: ``{limit: float}``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
HTTP-200 or HTTP error
"""
user = userController.get_user(userid)
data = request.get_json()
try:
limit = data["limit"]
except (TypeError, KeyError):
raise BadRequest
balance_controller.set_limit(user, limit)
return HTTP.no_content()
@balance_bp.route("/users/<userid>/balance", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_balance(userid, current_session: Session):
"""Get balance of user, optionally filtered
Route: ``/users/<userid>/balance`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string}```
Args:
userid: Userid of user to get balance from
current_session: Session sent with Authorization Header
Returns:
JSON object containing credit, debit and balance or HTTP error
"""
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
else:
start = datetime.fromtimestamp(0, tz=timezone.utc)
end = request.args.get("to")
if end:
end = from_iso_format(end)
else:
end = datetime.now(tz=timezone.utc)
balance = balance_controller.get_balance(user, start, end)
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@balance_bp.route("/users/<userid>/balance/transactions", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_transactions(userid, current_session: Session):
"""Get transactions of user, optionally filtered
Returns also count of transactions if limit is set (e.g. just count with limit = 0)
Route: ``/users/<userid>/balance/transactions`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}```
Args:
userid: Userid of user to get transactions from
current_session: Session sent with Authorization Header
Returns:
JSON Object {transactions: Transaction[], count?: number} or HTTP error
"""
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
end = request.args.get("to")
if end:
end = from_iso_format(end)
show_reversals = request.args.get("showReversals", False)
show_cancelled = request.args.get("showCancelled", True)
limit = request.args.get("limit")
offset = request.args.get("offset")
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if not isinstance(show_reversals, bool):
show_reversals = str2bool(show_reversals)
if not isinstance(show_cancelled, bool):
show_cancelled = str2bool(show_cancelled)
except ValueError:
raise BadRequest
transactions, count = balance_controller.get_transactions(
user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled
)
return {"transactions": transactions, "count": count}
@balance_bp.route("/users/<userid>/balance", methods=["PUT"])
@login_required()
def change_balance(userid, current_session: Session):
"""Change balance of an user
If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user.
Route: ``/users/<userid>/balance`` | Method: ``PUT``
POST-data: ``{amount: float, sender: string}``
Args:
userid: userid identifying user to change balance
current_session: Session sent with Authorization Header
Returns:
JSON encoded transaction (201) or HTTP error
"""
data = request.get_json()
try:
amount = data["amount"]
except (TypeError, KeyError):
raise BadRequest
sender = data.get("sender", None)
user = userController.get_user(userid)
if sender:
sender = userController.get_user(sender)
if sender == user:
raise BadRequest
if (sender == current_session.user_ and sender.has_permission(permissions.SEND)) or (
sender != current_session.user_ and current_session.user_.has_permission(permissions.SEND_OTHER)
):
return HTTP.created(balance_controller.send(sender, user, amount, current_session.user_))
elif (
amount < 0
and (
(user == current_session.user_ and user.has_permission(permissions.DEBIT_OWN))
or current_session.user_.has_permission(permissions.DEBIT)
)
) or (amount > 0 and current_session.user_.has_permission(permissions.CREDIT)):
return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session.user_))
raise Forbidden
@balance_bp.route("/balance/<int:transaction_id>", methods=["DELETE"])
@login_required()
def reverse_transaction(transaction_id, current_session: Session):
"""Reverse a transaction
Route: ``/balance/<int:transaction_id>`` | Method: ``DELETE``
Args:
transaction_id: Identifier of the transaction
current_session: Session sent with Authorization Header
Returns:
JSON encoded reversal (transaction) (201) or HTTP error
"""
transaction = balance_controller.get_transaction(transaction_id)
if current_session.user_.has_permission(permissions.REVERSAL) or (
transaction.sender_ == current_session.user_
and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10
):
reversal = balance_controller.reverse_transaction(transaction, current_session.user_)
return HTTP.created(reversal)
raise Forbidden
@balance_bp.route("/balance", methods=["GET"])
@login_required(permission=permissions.SHOW_OTHER)
def get_balances(current_session: Session):
"""Get all balances
Route: ``/balance`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
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()])

View File

@ -11,7 +11,7 @@ from flaschengeist.database import db
from flaschengeist.models.user import User
from .models import Transaction
from . import permissions
from . import permissions, BalancePlugin
__attribute_limit = "balance_limit"
@ -86,6 +86,10 @@ def send(sender: User, receiver, amount: float, author: User):
transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author)
db.session.add(transaction)
db.session.commit()
if sender is not None and sender.id_ != author.id_:
BalancePlugin.plugin.notify(sender, "Neue Transaktion")
if receiver is not None and receiver.id_ != author.id_:
BalancePlugin.plugin.notify(receiver, "Neue Transaktion")
return transaction

View File

@ -6,21 +6,21 @@ from sqlalchemy.ext.hybrid import hybrid_property
from flaschengeist.database import db
from flaschengeist.models.user import User
from flaschengeist.models import ModelSerializeMixin, UtcDateTime
from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
class Transaction(db.Model, ModelSerializeMixin):
__tablename__ = "balance_transaction"
# Protected foreign key properties
_receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id"))
_sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id"))
_author_id = db.Column("author_id", db.Integer, db.ForeignKey("user.id"), nullable=False)
_receiver_id = db.Column("receiver_id", Serial, db.ForeignKey("user.id"))
_sender_id = db.Column("sender_id", Serial, db.ForeignKey("user.id"))
_author_id = db.Column("author_id", Serial, db.ForeignKey("user.id"), nullable=False)
# Public and exported member
id: int = db.Column("id", db.Integer, primary_key=True)
id: int = db.Column("id", Serial, primary_key=True)
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
reversal_id: Optional[int] = db.Column(db.Integer, db.ForeignKey("balance_transaction.id"))
reversal_id: Optional[int] = db.Column(Serial, db.ForeignKey("balance_transaction.id"))
# Dummy properties used for JSON serialization (userid instead of full user)
author_id: Optional[str] = None

View File

@ -0,0 +1,279 @@
from datetime import datetime, timezone
from werkzeug.exceptions import Forbidden, BadRequest
from flask import request, jsonify
from flaschengeist.utils import HTTP
from flaschengeist.models.session import Session
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import userController
from . import BalancePlugin, balance_controller, permissions
def str2bool(string: str):
if string.lower() in ["true", "yes", "1"]:
return True
elif string.lower() in ["false", "no", "0"]:
return False
raise ValueError
@BalancePlugin.blueprint.route("/users/<userid>/balance/shortcuts", methods=["GET", "PUT"])
@login_required()
def get_shortcuts(userid, current_session: Session):
"""Get balance shortcuts of an user
Route: ``/users/<userid>/balance/shortcuts`` | 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 float 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("balance_shortcuts", []))
else:
data = request.get_json()
if not isinstance(data, list) or not all(isinstance(n, (int, float)) for n in data):
raise BadRequest
data.sort(reverse=True)
user.set_attribute("balance_shortcuts", data)
userController.persist()
return HTTP.no_content()
@BalancePlugin.blueprint.route("/users/<userid>/balance/limit", methods=["GET"])
@login_required()
def get_limit(userid, current_session: Session):
"""Get limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``GET``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
JSON object containing the limit (or Null if no limit set) or HTTP error
"""
user = userController.get_user(userid)
if (user != current_session.user_ and not current_session.user_.has_permission(permissions.SET_LIMIT)) or (
user == current_session.user_ and not user.has_permission(permissions.SHOW)
):
raise Forbidden
return {"limit": balance_controller.get_limit(user)}
@BalancePlugin.blueprint.route("/users/<userid>/balance/limit", methods=["PUT"])
@login_required(permissions.SET_LIMIT)
def set_limit(userid, current_session: Session):
"""Set the limit of an user
Route: ``/users/<userid>/balance/limit`` | Method: ``PUT``
POST-data: ``{limit: float}``
Args:
userid: Userid identifying the user
current_session: Session sent with Authorization Header
Returns:
HTTP-200 or HTTP error
"""
user = userController.get_user(userid)
data = request.get_json()
try:
limit = data["limit"]
except (TypeError, KeyError):
raise BadRequest
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):
"""Get balance of user, optionally filtered
Route: ``/users/<userid>/balance`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string}```
Args:
userid: Userid of user to get balance from
current_session: Session sent with Authorization Header
Returns:
JSON object containing credit, debit and balance or HTTP error
"""
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
else:
start = datetime.fromtimestamp(0, tz=timezone.utc)
end = request.args.get("to")
if end:
end = from_iso_format(end)
else:
end = datetime.now(tz=timezone.utc)
balance = balance_controller.get_balance(user, start, end)
return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@BalancePlugin.blueprint.route("/users/<userid>/balance/transactions", methods=["GET"])
@login_required(permission=permissions.SHOW)
def get_transactions(userid, current_session: Session):
"""Get transactions of user, optionally filtered
Returns also count of transactions if limit is set (e.g. just count with limit = 0)
Route: ``/users/<userid>/balance/transactions`` | Method: ``GET``
GET-parameters: ```{from?: string, to?: string, limit?: int, offset?: int}```
Args:
userid: Userid of user to get transactions from
current_session: Session sent with Authorization Header
Returns:
JSON Object {transactions: Transaction[], count?: number} or HTTP error
"""
if userid != current_session.user_.userid and not current_session.user_.has_permission(permissions.SHOW_OTHER):
raise Forbidden
# Might raise NotFound
user = userController.get_user(userid)
start = request.args.get("from")
if start:
start = from_iso_format(start)
end = request.args.get("to")
if end:
end = from_iso_format(end)
show_reversals = request.args.get("showReversals", False)
show_cancelled = request.args.get("showCancelled", True)
limit = request.args.get("limit")
offset = request.args.get("offset")
try:
if limit is not None:
limit = int(limit)
if offset is not None:
offset = int(offset)
if not isinstance(show_reversals, bool):
show_reversals = str2bool(show_reversals)
if not isinstance(show_cancelled, bool):
show_cancelled = str2bool(show_cancelled)
except ValueError:
raise BadRequest
transactions, count = balance_controller.get_transactions(
user, start, end, limit, offset, show_reversal=show_reversals, show_cancelled=show_cancelled
)
return {"transactions": transactions, "count": count}
@BalancePlugin.blueprint.route("/users/<userid>/balance", methods=["PUT"])
@login_required()
def change_balance(userid, current_session: Session):
"""Change balance of an user
If ``sender`` is preset in POST-data, the action is handled as a transfer from ``sender`` to user.
Route: ``/users/<userid>/balance`` | Method: ``PUT``
POST-data: ``{amount: float, sender: string}``
Args:
userid: userid identifying user to change balance
current_session: Session sent with Authorization Header
Returns:
JSON encoded transaction (201) or HTTP error
"""
data = request.get_json()
try:
amount = data["amount"]
except (TypeError, KeyError):
raise BadRequest
sender = data.get("sender", None)
user = userController.get_user(userid)
if sender:
sender = userController.get_user(sender)
if sender == user:
raise BadRequest
if (sender == current_session.user_ and sender.has_permission(permissions.SEND)) or (
sender != current_session.user_ and current_session.user_.has_permission(permissions.SEND_OTHER)
):
return HTTP.created(balance_controller.send(sender, user, amount, current_session.user_))
elif (
amount < 0
and (
(user == current_session.user_ and user.has_permission(permissions.DEBIT_OWN))
or current_session.user_.has_permission(permissions.DEBIT)
)
) or (amount > 0 and current_session.user_.has_permission(permissions.CREDIT)):
return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session.user_))
raise Forbidden
@BalancePlugin.blueprint.route("/balance/<int:transaction_id>", methods=["DELETE"])
@login_required()
def reverse_transaction(transaction_id, current_session: Session):
"""Reverse a transaction
Route: ``/balance/<int:transaction_id>`` | Method: ``DELETE``
Args:
transaction_id: Identifier of the transaction
current_session: Session sent with Authorization Header
Returns:
JSON encoded reversal (transaction) (201) or HTTP error
"""
transaction = balance_controller.get_transaction(transaction_id)
if current_session.user_.has_permission(permissions.REVERSAL) or (
transaction.sender_ == current_session.user_
and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10
):
reversal = balance_controller.reverse_transaction(transaction, current_session.user_)
return HTTP.created(reversal)
raise Forbidden
@BalancePlugin.blueprint.route("/balance", methods=["GET"])
@login_required(permission=permissions.SHOW_OTHER)
def get_balances(current_session: Session):
"""Get all balances
Route: ``/balance`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
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()])

View File

@ -1,441 +1,21 @@
"""Schedule plugin
"""Events plugin
Provides duty schedule / duty roster functions
"""
from datetime import datetime, timedelta, timezone
from http.client import NO_CONTENT
from flask import Blueprint, request, jsonify
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
from flask import Blueprint, current_app
from werkzeug.local import LocalProxy
from flaschengeist.plugins import Plugin
from flaschengeist.models.session import Session
from flaschengeist.utils.decorators import login_required
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.controller import userController
from . import event_controller, permissions
from . import models
from ...utils.HTTP import no_content
events_bp = Blueprint("events", __name__)
from . import permissions, models
class EventPlugin(Plugin):
name = "events"
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][EventPlugin.name])
permissions = permissions.permissions
blueprint = Blueprint(name, __name__)
models = models
def __init__(self, config):
super().__init__(
blueprint=events_bp,
permissions=permissions.permissions,
)
@events_bp.route("/events/templates", methods=["GET"])
@login_required()
def get_templates(current_session):
return jsonify(event_controller.get_templates())
@events_bp.route("/events/event-types", methods=["GET"])
@events_bp.route("/events/event-types/<int:identifier>", methods=["GET"])
@login_required()
def get_event_types(current_session, identifier=None):
"""Get EventType(s)
Route: ``/events/event-types`` | Method: ``GET``
Route: ``/events/event-types/<identifier>`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
identifier: If querying a specific EventType
Returns:
JSON encoded (list of) EventType(s) or HTTP-error
"""
if identifier:
result = event_controller.get_event_type(identifier)
else:
result = event_controller.get_event_types()
return jsonify(result)
@events_bp.route("/events/event-types", methods=["POST"])
@login_required(permission=permissions.EVENT_TYPE)
def new_event_type(current_session):
"""Create a new EventType
Route: ``/events/event-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
HTTP-Created or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
event_type = event_controller.create_event_type(data["name"])
return jsonify(event_type)
@events_bp.route("/events/event-types/<int:identifier>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.EVENT_TYPE)
def modify_event_type(identifier, current_session):
"""Rename or delete an event type
Route: ``/events/event-types/<id>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
identifier: Identifier of the EventType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_event_type(identifier)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_event_type(identifier, data["name"])
return "", NO_CONTENT
@events_bp.route("/events/job-types", methods=["GET"])
@login_required()
def get_job_types(current_session):
"""Get all JobTypes
Route: ``/events/job-types`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of JobType HTTP-error
"""
types = event_controller.get_job_types()
return jsonify(types)
@events_bp.route("/events/job-types", methods=["POST"])
@login_required(permission=permissions.JOB_TYPE)
def new_job_type(current_session):
"""Create a new JobType
Route: ``/events/job-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded JobType or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
jt = event_controller.create_job_type(data["name"])
return jsonify(jt)
@events_bp.route("/events/job-types/<int:type_id>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.JOB_TYPE)
def modify_job_type(type_id, current_session):
"""Rename or delete a JobType
Route: ``/events/job-types/<name>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
type_id: Identifier of the JobType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_job_type(type_id)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_job_type(type_id, data["name"])
return "", NO_CONTENT
@events_bp.route("/events/<int:event_id>", methods=["GET"])
@login_required()
def get_event(event_id, current_session):
"""Get event by id
Route: ``/events/<event_id>`` | Method: ``GET``
Args:
event_id: ID identifying the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded event object
"""
event = event_controller.get_event(event_id)
return jsonify(event)
@events_bp.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))
@events_bp.route("/events/<int:year>/<int:month>", methods=["GET"])
@events_bp.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)
return jsonify(events)
except ValueError:
raise BadRequest("Invalid date given")
def _add_job(event, data):
try:
start = from_iso_format(data["start"])
end = None
if "end" in data:
end = from_iso_format(data["end"])
required_services = data["required_services"]
job_type = data["type"]
if isinstance(job_type, dict):
job_type = data["type"]["id"]
except (KeyError, ValueError):
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))
@events_bp.route("/events", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_event(current_session):
"""Create an new event
Route: ``/events`` | Method: ``POST``
POST-data: See interfaces for Event, can already contain jobs
Args:
current_session: Session sent with Authorization Header
Returns:
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)
data_type = data["type"]
if isinstance(data_type, dict):
data_type = data["type"]["id"]
event_type = event_controller.get_event_type(data_type)
except KeyError:
raise BadRequest("Missing POST parameter")
except (NotFound, ValueError):
raise BadRequest("Invalid parameter")
event = event_controller.create_event(
start=start,
end=end,
name=data.get("name", None),
is_template=data.get("is_template", None),
event_type=event_type,
description=data.get("description", None),
)
if "jobs" in data:
for job in data["jobs"]:
_add_job(event, job)
return jsonify(event)
@events_bp.route("/events/<int:event_id>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def modify_event(event_id, current_session):
"""Modify an event
Route: ``/events/<event_id>`` | Method: ``PUT``
POST-data: See interfaces for Event, can already contain slots
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
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"]
if "type" in data:
event_type = event_controller.get_event_type(data["type"])
event.type = event_type
event_controller.update()
return jsonify(event)
@events_bp.route("/events/<int:event_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_event(event_id, current_session):
"""Delete an event
Route: ``/events/<event_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
event_controller.delete_event(event_id)
return "", NO_CONTENT
@events_bp.route("/events/<int:event_id>/jobs", methods=["POST"])
@login_required(permission=permissions.EDIT)
def add_job(event_id, current_session):
"""Add an new Job to an Event / EventSlot
Route: ``/events/<event_id>/jobs`` | Method: ``POST``
POST-data: See Job
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
event = event_controller.get_event(event_id)
_add_job(event, request.get_json())
return jsonify(event)
@events_bp.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_job(event_id, job_id, current_session):
"""Delete a Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-no-content or HTTP error
"""
job_slot = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job_slot)
return no_content()
@events_bp.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
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``PUT``
POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
JSON encoded Job object or HTTP-error
"""
job = event_controller.get_job(job_id, event_id)
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()
return jsonify(job)
# TODO: JobTransfer
def __init__(self, cfg):
super(EventPlugin, self).__init__(cfg)
from . import routes

View File

@ -1,14 +1,14 @@
from datetime import datetime
from datetime import datetime, timedelta, timezone
from typing import Optional
from sqlalchemy import or_, and_
from werkzeug.exceptions import BadRequest, NotFound
from sqlalchemy.exc import IntegrityError
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.utils.datetime import from_iso_format
from flaschengeist.utils.scheduler import scheduled
def update():
@ -102,10 +102,21 @@ def delete_job_type(name):
raise BadRequest("Type still in use")
def get_event(event_id) -> Event:
def clear_backup(event: Event):
for job in event.jobs:
services = []
for service in job.services:
if not service.is_backup:
services.append(service)
job.services = services
def get_event(event_id, with_backup=False) -> Event:
event = Event.query.get(event_id)
if event is None:
raise NotFound
if not with_backup:
return clear_backup(event)
return event
@ -113,11 +124,12 @@ def get_templates():
return Event.query.filter(Event.is_template == True).all()
def get_events(start: Optional[datetime] = None, end=None):
def get_events(start: Optional[datetime] = None, end=None, with_backup=False):
"""Query events which start from begin until end
Args:
start (datetime): Earliest start
end (datetime): Latest start
with_backup (bool): Export also backup services
Returns: collection of Event objects
"""
@ -126,7 +138,11 @@ def get_events(start: Optional[datetime] = None, end=None):
query = query.filter(start <= Event.start)
if end is not None:
query = query.filter(Event.start < end)
return query.all()
events = query.all()
if not with_backup:
for event in events:
clear_backup(event)
return events
def delete_event(event_id):
@ -202,3 +218,26 @@ def assign_to_job(job: Job, user, value):
service = Service(user_=user, value=value, job_=job)
db.session.add(service)
db.session.commit()
@scheduled
def assign_backups():
logger.debug("Notifications")
now = datetime.now(tz=timezone.utc)
# now + backup_time + next cron tick
start = now + timedelta(hours=16) + timedelta(minutes=30)
services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all()
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_}
)
logger.debug(f"Service is outdated or full, removing. {service.serialize()}")
db.session.delete(service)
else:
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_}
)
db.session.commit()

View File

@ -1,12 +1,11 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
import enum
from datetime import datetime
from typing import Optional, Union
from sqlalchemy import UniqueConstraint
from flaschengeist.models import ModelSerializeMixin, UtcDateTime
from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
from flaschengeist.models.user import User
from flaschengeist.database import db
@ -19,13 +18,13 @@ _table_prefix_ = "events_"
class EventType(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "event_type"
id: int = db.Column(db.Integer, primary_key=True)
id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
class JobType(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "job_type"
id: int = db.Column(db.Integer, primary_key=True)
id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
@ -37,12 +36,11 @@ class JobType(db.Model, ModelSerializeMixin):
class Service(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "service"
userid: str = ""
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", db.Integer, db.ForeignKey(f"{_table_prefix_}job.id"), nullable=False, primary_key=True
)
_user_id = db.Column("user_id", db.Integer, db.ForeignKey("user.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")
job_: Job = db.relationship("Job")
@ -54,9 +52,9 @@ class Service(db.Model, ModelSerializeMixin):
class Job(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "job"
_type_id = db.Column("type_id", db.Integer, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False)
_type_id = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False)
id: int = db.Column(db.Integer, primary_key=True)
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")
@ -65,7 +63,7 @@ class Job(db.Model, ModelSerializeMixin):
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", db.Integer, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False)
event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False)
__table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),)
@ -77,7 +75,7 @@ class Event(db.Model, ModelSerializeMixin):
"""Model for an Event"""
__tablename__ = _table_prefix_ + "event"
id: int = db.Column(db.Integer, primary_key=True)
id: int = db.Column(Serial, primary_key=True)
start: datetime = db.Column(UtcDateTime, nullable=False)
end: Optional[datetime] = db.Column(UtcDateTime)
name: Optional[str] = db.Column(db.String(255))
@ -89,15 +87,15 @@ class Event(db.Model, ModelSerializeMixin):
)
# Protected for internal use
_type_id = db.Column(
"type_id", db.Integer, 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"
id: int = db.Column(db.Integer, primary_key=True)
job_id: int = db.Column(db.Integer, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False)
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
@ -105,8 +103,8 @@ class Invite(db.Model, ModelSerializeMixin):
invitee_: User = db.relationship("User", foreign_keys="Invite._invitee_id")
sender_: User = db.relationship("User", foreign_keys="Invite._sender_id")
# Protected properties needed for internal use
_invitee_id = db.Column("invitee_id", db.Integer, db.ForeignKey("user.id"), nullable=False)
_sender_id = db.Column("sender_id", db.Integer, db.ForeignKey("user.id"), nullable=False)
_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)
@property
def invitee_id(self):

View File

@ -1,22 +1,25 @@
CREATE = "schedule_create"
CREATE = "events_create"
"""Can create events"""
EDIT = "schedule_edit"
EDIT = "events_edit"
"""Can edit events"""
DELETE = "schedule_delete"
DELETE = "events_delete"
"""Can delete events"""
EVENT_TYPE = "schedule_event_type"
EVENT_TYPE = "events_event_type"
"""Can create and edit EventTypes"""
JOB_TYPE = "schedule_job_type"
JOB_TYPE = "events_job_type"
"""Can create and edit JobTypes"""
ASSIGN = "schedule_assign"
ASSIGN = "events_assign"
"""Can self assign to jobs"""
ASSIGN_OTHER = "schedule_assign_other"
ASSIGN_OTHER = "events_assign_other"
"""Can assign other users to jobs"""
SEE_BACKUP = "events_see_backup"
"""Can see users assigned as backup"""
permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -0,0 +1,431 @@
from datetime import datetime, timedelta, timezone
from http.client import NO_CONTENT
from flask import request, jsonify
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
from flaschengeist.models.session import Session
from flaschengeist.utils.decorators import login_required
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
@EventPlugin.blueprint.route("/events/templates", methods=["GET"])
@login_required()
def get_templates(current_session):
return jsonify(event_controller.get_templates())
@EventPlugin.blueprint.route("/events/event-types", methods=["GET"])
@EventPlugin.blueprint.route("/events/event-types/<int:identifier>", methods=["GET"])
@login_required()
def get_event_types(current_session, identifier=None):
"""Get EventType(s)
Route: ``/events/event-types`` | Method: ``GET``
Route: ``/events/event-types/<identifier>`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
identifier: If querying a specific EventType
Returns:
JSON encoded (list of) EventType(s) or HTTP-error
"""
if identifier:
result = event_controller.get_event_type(identifier)
else:
result = event_controller.get_event_types()
return jsonify(result)
@EventPlugin.blueprint.route("/events/event-types", methods=["POST"])
@login_required(permission=permissions.EVENT_TYPE)
def new_event_type(current_session):
"""Create a new EventType
Route: ``/events/event-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
HTTP-Created or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
event_type = event_controller.create_event_type(data["name"])
return jsonify(event_type)
@EventPlugin.blueprint.route("/events/event-types/<int:identifier>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.EVENT_TYPE)
def modify_event_type(identifier, current_session):
"""Rename or delete an event type
Route: ``/events/event-types/<id>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
identifier: Identifier of the EventType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_event_type(identifier)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_event_type(identifier, data["name"])
return "", NO_CONTENT
@EventPlugin.blueprint.route("/events/job-types", methods=["GET"])
@login_required()
def get_job_types(current_session):
"""Get all JobTypes
Route: ``/events/job-types`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of JobType HTTP-error
"""
types = event_controller.get_job_types()
return jsonify(types)
@EventPlugin.blueprint.route("/events/job-types", methods=["POST"])
@login_required(permission=permissions.JOB_TYPE)
def new_job_type(current_session):
"""Create a new JobType
Route: ``/events/job-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded JobType or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
jt = event_controller.create_job_type(data["name"])
return jsonify(jt)
@EventPlugin.blueprint.route("/events/job-types/<int:type_id>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.JOB_TYPE)
def modify_job_type(type_id, current_session):
"""Rename or delete a JobType
Route: ``/events/job-types/<name>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
type_id: Identifier of the JobType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_job_type(type_id)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_job_type(type_id, data["name"])
return "", NO_CONTENT
@EventPlugin.blueprint.route("/events/<int:event_id>", methods=["GET"])
@login_required()
def get_event(event_id, current_session):
"""Get event by id
Route: ``/events/<event_id>`` | Method: ``GET``
Args:
event_id: ID identifying the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded event object
"""
event = event_controller.get_event(
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)
)
)
@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")
def _add_job(event, data):
try:
start = from_iso_format(data["start"])
end = None
if "end" in data:
end = from_iso_format(data["end"])
required_services = data["required_services"]
job_type = data["type"]
if isinstance(job_type, dict):
job_type = data["type"]["id"]
except (KeyError, ValueError):
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))
@EventPlugin.blueprint.route("/events", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_event(current_session):
"""Create an new event
Route: ``/events`` | Method: ``POST``
POST-data: See interfaces for Event, can already contain jobs
Args:
current_session: Session sent with Authorization Header
Returns:
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)
data_type = data["type"]
if isinstance(data_type, dict):
data_type = data["type"]["id"]
event_type = event_controller.get_event_type(data_type)
except KeyError:
raise BadRequest("Missing POST parameter")
except (NotFound, ValueError):
raise BadRequest("Invalid parameter")
event = event_controller.create_event(
start=start,
end=end,
name=data.get("name", None),
is_template=data.get("is_template", None),
event_type=event_type,
description=data.get("description", None),
)
if "jobs" in data:
for job in data["jobs"]:
_add_job(event, job)
return jsonify(event)
@EventPlugin.blueprint.route("/events/<int:event_id>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def modify_event(event_id, current_session):
"""Modify an event
Route: ``/events/<event_id>`` | Method: ``PUT``
POST-data: See interfaces for Event, can already contain slots
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
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"]
if "type" in data:
event_type = event_controller.get_event_type(data["type"])
event.type = event_type
event_controller.update()
return jsonify(event)
@EventPlugin.blueprint.route("/events/<int:event_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_event(event_id, current_session):
"""Delete an event
Route: ``/events/<event_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
event_controller.delete_event(event_id)
return "", NO_CONTENT
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs", methods=["POST"])
@login_required(permission=permissions.EDIT)
def add_job(event_id, current_session):
"""Add an new Job to an Event / EventSlot
Route: ``/events/<event_id>/jobs`` | Method: ``POST``
POST-data: See Job
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
event = event_controller.get_event(event_id)
_add_job(event, request.get_json())
return jsonify(event)
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_job(event_id, job_id, current_session):
"""Delete a Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-no-content or HTTP error
"""
job_slot = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job_slot)
return no_content()
@EventPlugin.blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"])
@login_required()
def update_job(event_id, job_id, current_session: Session):
"""Edit Job or assign user to the Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``PUT``
POST-data: See TS interface for Job or ``{user: {userid: string, value: number}}``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
JSON encoded Job object or HTTP-error
"""
job = event_controller.get_job(job_id, event_id)
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()
return jsonify(job)
# TODO: JobTransfer

View File

@ -1,42 +1,42 @@
"""Pricelist plugin"""
from flask import Blueprint, jsonify, request
from http.client import NO_CONTENT
from flask import Blueprint, jsonify, request, current_app
from werkzeug.local import LocalProxy
from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized
from flaschengeist.plugins import Plugin
from flaschengeist.utils.decorators import login_required, extract_session
from werkzeug.exceptions import BadRequest, Forbidden, Unauthorized
from flaschengeist.config import config
from flaschengeist.utils.HTTP import no_content
from flaschengeist.models.session import Session
from flaschengeist.controller import userController
from . import models
from . import pricelist_controller, permissions
from ...controller import userController
from ...models.session import Session
from ...utils.HTTP import no_content
pricelist_bp = Blueprint("pricelist", __name__, url_prefix="/pricelist")
class PriceListPlugin(Plugin):
name = "pricelist"
blueprint = Blueprint(name, __name__, url_prefix="/pricelist")
plugin = LocalProxy(lambda: current_app.config["FG_PLUGINS"][PriceListPlugin.name])
models = models
def __init__(self, cfg):
super().__init__(blueprint=pricelist_bp, permissions=permissions.permissions)
super().__init__(cfg)
config = {"discount": 0}
config.update(cfg)
@pricelist_bp.route("/drink-types", methods=["GET"])
@pricelist_bp.route("/drink-types/<int:identifier>", methods=["GET"])
@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"])
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["GET"])
def get_drink_types(identifier=None):
if identifier:
result = pricelist_controller.get_drink_type(identifier)
else:
if identifier is None:
result = pricelist_controller.get_drink_types()
else:
result = pricelist_controller.get_drink_type(identifier)
return jsonify(result)
@pricelist_bp.route("/drink-types", methods=["POST"])
@PriceListPlugin.blueprint.route("/drink-types", methods=["POST"])
@login_required(permission=permissions.CREATE_TYPE)
def new_drink_type(current_session):
data = request.get_json()
@ -46,25 +46,25 @@ def new_drink_type(current_session):
return jsonify(drink_type)
@pricelist_bp.route("/drink-types/<int:identifier>", methods=["PUT"])
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TYPE)
def update_drink_type(identifier, current_session):
data = request.get_json()
if "name" not in data:
raise BadRequest
drink_type = pricelist_controller.rename_drink_type(data["id"], data["name"])
drink_type = pricelist_controller.rename_drink_type(identifier, data["name"])
return jsonify(drink_type)
@pricelist_bp.route("/drink-types/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/drink-types/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TYPE)
def delete_drink_type(identifier, current_session):
pricelist_controller.delete_drink_type(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/tags", methods=["GET"])
@pricelist_bp.route("/tags/<int:identifier>", methods=["GET"])
@PriceListPlugin.blueprint.route("/tags", methods=["GET"])
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["GET"])
def get_tags(identifier=None):
if identifier:
result = pricelist_controller.get_tag(identifier)
@ -73,7 +73,7 @@ def get_tags(identifier=None):
return jsonify(result)
@pricelist_bp.route("/tags", methods=["POST"])
@PriceListPlugin.blueprint.route("/tags", methods=["POST"])
@login_required(permission=permissions.CREATE_TAG)
def new_tag(current_session):
data = request.get_json()
@ -83,25 +83,25 @@ def new_tag(current_session):
return jsonify(drink_type)
@pricelist_bp.route("/tags/<int:identifier>", methods=["PUT"])
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["PUT"])
@login_required(permission=permissions.EDIT_TAG)
def update_tag(identifier, current_session):
data = request.get_json()
if "name" not in data:
raise BadRequest
drink_type = pricelist_controller.rename_tag(data["name"])
return jsonify(drink_type)
tag = pricelist_controller.rename_tag(identifier, data["name"])
return jsonify(tag)
@pricelist_bp.route("/tags/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/tags/<int:identifier>", methods=["DELETE"])
@login_required(permission=permissions.DELETE_TAG)
def delete_tag(identifier, current_session):
pricelist_controller.delete_tag(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/drinks", methods=["GET"])
@pricelist_bp.route("/drinks/<int:identifier>", methods=["GET"])
@PriceListPlugin.blueprint.route("/drinks", methods=["GET"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["GET"])
def get_drinks(identifier=None):
public = True
try:
@ -116,85 +116,87 @@ def get_drinks(identifier=None):
result = pricelist_controller.get_drinks(public=public)
return jsonify(result)
@pricelist_bp.route("/drinks/search/<string:name>", methods=["GET"])
@PriceListPlugin.blueprint.route("/drinks/search/<string:name>", methods=["GET"])
def search_drinks(name):
return jsonify(pricelist_controller.get_drinks(name))
@pricelist_bp.route("/drinks", methods=["POST"])
@PriceListPlugin.blueprint.route("/drinks", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_drink(current_session):
data = request.get_json()
return jsonify(pricelist_controller.set_drink(data))
@pricelist_bp.route("/drinks/<int:identifier>", methods=["PUT"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["PUT"])
def update_drink(identifier):
data = request.get_json()
return jsonify(pricelist_controller.update_drink(identifier, data))
@pricelist_bp.route("/drinks/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/drinks/<int:identifier>", methods=["DELETE"])
def delete_drink(identifier):
pricelist_controller.delete_drink(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/prices/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/prices/<int:identifier>", methods=["DELETE"])
def delete_price(identifier):
pricelist_controller.delete_price(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/volumes/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/volumes/<int:identifier>", methods=["DELETE"])
def delete_volume(identifier):
pricelist_controller.delete_volume(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/ingredients/extraIngredients", methods=["GET"])
def get_extraIngredients():
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["GET"])
def get_extra_ingredients():
return jsonify(pricelist_controller.get_extra_ingredients())
@pricelist_bp.route("/ingredients/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/ingredients/<int:identifier>", methods=["DELETE"])
def delete_ingredient(identifier):
pricelist_controller.delete_ingredient(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/ingredients/extraIngredients", methods=["POST"])
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients", methods=["POST"])
def set_extra_ingredient():
data = request.get_json()
return jsonify(pricelist_controller.set_extra_ingredient(data))
@pricelist_bp.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["PUT"])
def update_extra_ingredient(identifier):
data = request.get_json()
return jsonify(pricelist_controller.update_extra_ingredient(identifier, data))
@pricelist_bp.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/<int:identifier>", methods=["DELETE"])
def delete_extra_ingredient(identifier):
pricelist_controller.delete_extra_ingredient(identifier)
return "", NO_CONTENT
return no_content()
@pricelist_bp.route("/settings/min_prices", methods=["POST", "GET"])
@PriceListPlugin.blueprint.route("/settings/min_prices", methods=["POST", "GET"])
def pricelist_settings_min_prices():
if request.method == "GET":
return jsonify(PriceListPlugin.get_setting(PriceListPlugin, "min_prices"))
# 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.set_setting(PriceListPlugin, "min_prices", data)
PriceListPlugin.plugin.set_setting("min_prices", data)
return no_content()
@pricelist_bp.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
@PriceListPlugin.blueprint.route("/users/<userid>/pricecalc_columns", methods=["GET", "PUT"])
@login_required()
def get_columns(userid, current_session: Session):
"""Get pricecalc_columns of an user
@ -225,7 +227,7 @@ def get_columns(userid, current_session: Session):
userController.persist()
return no_content()
@pricelist_bp.route("/drinks/<int:identifier>/picture", methods=["POST", "GET", "DELETE"])
@PriceListPlugin.route("/drinks/<int:identifier>/picture", methods=["POST", "GET", "DELETE"])
def set_picture(identifier):
if request.method == "DELETE":
@ -241,10 +243,10 @@ def set_picture(identifier):
else:
raise BadRequest
@pricelist_bp.route("/picture/<identifier>", methods=["GET"])
@PriceListPlugin.route("/picture/<identifier>", methods=["GET"])
def _get_picture(identifier):
if request.method == "GET":
size = request.args.get("size")
path = config["pricelist"]["path"]
path = PriceListPlugin.plugin["path"]
response = pricelist_controller.get_drink_picture(identifier, size)
return response.make_conditional(request)

View File

@ -3,7 +3,7 @@ from __future__ import annotations # TODO: Remove if python requirement is >= 3
from flaschengeist.database import db
from flaschengeist.models import ModelSerializeMixin
from typing import Optional, Union
from typing import Optional
drink_tag_association = db.Table(
"drink_x_tag",
@ -71,7 +71,7 @@ class DrinkIngredient(db.Model, ModelSerializeMixin):
__tablename__ = "drink_ingredient"
id: int = db.Column("id", db.Integer, primary_key=True)
volume: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False)
drink_ingredient_id: int = db.Column("drink_ingredient_id", db.Integer, db.ForeignKey("drink.id"))
ingredient_id: int = db.Column(db.Integer, db.ForeignKey("drink.id"))
# drink_ingredient: Drink = db.relationship("Drink")
# price: float = 0
@ -92,11 +92,12 @@ class Ingredient(db.Model, ModelSerializeMixin):
__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_id = db.Column(db.Integer, db.ForeignKey("drink_ingredient.id"))
drink_ingredient: Optional[DrinkIngredient] = db.relationship(DrinkIngredient)
extra_ingredient_id = db.Column(db.Integer, db.ForeignKey("extra_ingredient.id"))
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"))
class MinPrices(ModelSerializeMixin):
"""
@ -134,8 +135,8 @@ class Drink(db.Model, ModelSerializeMixin):
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_price_pro_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
cost_price_package_netto: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
cost_per_volume: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
cost_per_package: Optional[float] = db.Column(db.Numeric(precision=5, scale=3, asdecimal=False))
uuid: str = db.Column(db.String(36))
receipt: Optional[str] = db.Column(db.String)

View File

@ -1,15 +1,14 @@
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 .models import Drink, DrinkPrice, Ingredient, Tag, DrinkType, DrinkPriceVolume, DrinkIngredient, ExtraIngredient
from flaschengeist.utils.picture import save_picture, get_picture, delete_picture
from uuid import uuid4
def update():
db.session.commit()
@ -21,15 +20,15 @@ def get_tags():
def get_tag(identifier):
if isinstance(identifier, int):
retVal = Tag.query.get(identifier)
ret = Tag.query.get(identifier)
elif isinstance(identifier, str):
retVal = Tag.query.filter(Tag.name == identifier).one_or_none()
ret = Tag.query.filter(Tag.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for Tag")
raise BadRequest
if not retVal:
if ret is None:
raise NotFound
return retVal
return ret
def create_tag(name):
@ -66,23 +65,23 @@ def get_drink_types():
def get_drink_type(identifier):
if isinstance(identifier, int):
retVal = DrinkType.query.get(identifier)
ret = DrinkType.query.get(identifier)
elif isinstance(identifier, str):
retVal = DrinkType.query.filter(Tag.name == identifier).one_or_none()
ret = DrinkType.query.filter(Tag.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for DrinkType")
raise BadRequest
if not retVal:
if ret is None:
raise NotFound
return retVal
return ret
def create_drink_type(name):
try:
drinkType = DrinkType(name=name)
db.session.add(drinkType)
drink_type = DrinkType(name=name)
db.session.add(drink_type)
update()
return drinkType
return drink_type
except IntegrityError:
raise BadRequest("Name already exists")
@ -98,8 +97,8 @@ def rename_drink_type(identifier, new_name):
def delete_drink_type(identifier):
drinkType = get_drink_type(identifier)
db.session.delete(drinkType)
drink_type = get_drink_type(identifier)
db.session.delete(drink_type)
try:
update()
except IntegrityError:
@ -137,13 +136,12 @@ def get_drink(identifier, public=False):
elif isinstance(identifier, str):
drink = Drink.query.filter(Tag.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for Drink")
raise BadRequest
if drink:
raise BadRequest("Invalid identifier type for Drink")
if drink is None:
raise NotFound
if public:
return _create_public_drink(drink)
return drink
raise NotFound
def set_drink(data):
@ -151,44 +149,40 @@ def set_drink(data):
def update_drink(identifier, data):
allowedKeys = Drink().serialize().keys()
try:
if "id" in data:
data.pop("id")
if "volumes" in data:
volumes = data.pop("volumes")
volumes = data.pop("volumes") if "volumes" in data else None
if "tags" in data:
data.pop("tags")
type = None
if "type" in data:
_type = data.pop("type")
if isinstance(_type, dict) and "id" in _type:
type = get_drink_type(_type.get("id"))
drink_type = data.pop("type")
if isinstance(drink_type, dict) and "id" in drink_type:
drink_type = drink_type["id"]
drink_type = get_drink_type(drink_type)
if identifier == -1:
drink = Drink()
db.session.add(drink)
else:
drink = get_drink(identifier)
if not drink:
raise NotFound
for key, value in data.items():
if hasattr(drink, key):
setattr(drink, key, value if value != "" else None)
if type:
drink.type = type
if volumes:
if drink_type:
drink.type = drink_type
if volumes is not None:
set_volumes(volumes, drink)
db.session.commit()
return drink
except (NotFound, KeyError):
raise BadRequest
def set_volumes(volumes, drink):
if isinstance(volumes, list):
_volumes = []
for _volume in volumes:
volume = set_volume(_volume)
_volumes.append(volume)
drink.volumes = _volumes
if not isinstance(volumes, list):
raise BadRequest
for volume in volumes:
drink.volumes.append(set_volume(volume))
def delete_drink(identifier):
@ -216,15 +210,12 @@ def set_volume(data):
prices = values.pop("prices")
if "ingredients" in values:
ingredients = values.pop("ingredients")
id = None
if "id" in values:
id = values.pop("id")
volume = None
if id < 0:
vol_id = values.pop("id", None)
if vol_id < 0:
volume = DrinkPriceVolume(**values)
db.session.add(volume)
else:
volume = get_volume(id)
volume = get_volume(vol_id)
if not volume:
raise NotFound
for key, value in values.items():
@ -276,15 +267,12 @@ def get_prices(volume_id=None):
def set_price(data):
allowed_keys = DrinkPrice().serialize().keys()
values = {key: value for key, value in data.items() if key in allowed_keys}
id = None
if "id" in values:
id = values.pop("id")
price = None
if id < 0:
price_id = values.pop("id", -1)
if price_id < 0:
price = DrinkPrice(**values)
db.session.add(price)
else:
price = get_price(id)
price = get_price(price_id)
if not price:
raise NotFound
for key, value in values.items():
@ -300,17 +288,14 @@ def delete_price(identifier):
def set_drink_ingredient(data):
allowedKeys = DrinkIngredient().serialize().keys()
drink = None
values = {key: value for key, value in data.items() if key in allowedKeys}
id = None
if "id" in values:
id = values.pop("id")
if id < 0:
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(id)
drink_ingredient = DrinkIngredient.query.get(ingredient_id)
if not drink_ingredient:
raise NotFound
for key, value in values.items():
@ -329,15 +314,12 @@ def set_ingredient(data):
drink_ingredient_value = data.pop("drink_ingredient")
if "extra_ingredient" in data:
extra_ingredient_value = data.pop("extra_ingredient")
id = None
if "id" in data:
id = data.pop("id")
ingredient = None
if id < 0:
ingredient_id = data.pop("id", -1)
if ingredient_id < 0:
ingredient = Ingredient(**data)
db.session.add(ingredient)
else:
ingredient = get_ingredient(id)
ingredient = get_ingredient(ingredient_id)
if not ingredient:
raise NotFound
if drink_ingredient_value:
@ -365,10 +347,10 @@ def get_extra_ingredient(identifier):
def set_extra_ingredient(data):
allowedKeys = ExtraIngredient().serialize().keys()
allowed_keys = ExtraIngredient().serialize().keys()
if "id" in data:
data.pop("id")
values = {key: value for key, value in data.items() if key in allowedKeys}
values = {key: value for key, value in data.items() if key in allowed_keys}
extra_ingredient = ExtraIngredient(**values)
db.session.add(extra_ingredient)
db.session.commit()
@ -376,10 +358,10 @@ def set_extra_ingredient(data):
def update_extra_ingredient(identifier, data):
allowedKeys = ExtraIngredient().serialize().keys()
allowed_keys = ExtraIngredient().serialize().keys()
if "id" in data:
data.pop("id")
values = {key: value for key, value in data.items() if key in allowedKeys}
values = {key: value for key, value in data.items() if key in allowed_keys}
extra_ingredient = get_extra_ingredient(identifier)
if extra_ingredient:
for key, value in values.items():

View File

@ -12,17 +12,17 @@ from flaschengeist.utils.decorators import login_required
from flaschengeist.controller import roleController
from flaschengeist.utils.HTTP import created
roles_bp = Blueprint("roles", __name__)
_permission_edit = "roles_edit"
_permission_delete = "roles_delete"
class RolesPlugin(Plugin):
def __init__(self, config):
super().__init__(config, roles_bp, permissions=[_permission_edit, _permission_delete])
name = "roles"
blueprint = Blueprint(name, __name__)
permissions = [_permission_edit, _permission_delete]
@roles_bp.route("/roles", methods=["GET"])
@RolesPlugin.blueprint.route("/roles", methods=["GET"])
@login_required()
def list_roles(current_session):
"""List all existing roles
@ -39,7 +39,7 @@ def list_roles(current_session):
return jsonify(roles)
@roles_bp.route("/roles", methods=["POST"])
@RolesPlugin.blueprint.route("/roles", methods=["POST"])
@login_required(permission=_permission_edit)
def create_role(current_session):
"""Create new role
@ -62,7 +62,7 @@ def create_role(current_session):
return created(roleController.create_role(data["name"], permissions))
@roles_bp.route("/roles/permissions", methods=["GET"])
@RolesPlugin.blueprint.route("/roles/permissions", methods=["GET"])
@login_required()
def list_permissions(current_session):
"""List all existing permissions
@ -79,7 +79,7 @@ def list_permissions(current_session):
return jsonify(permissions)
@roles_bp.route("/roles/<role_name>", methods=["GET"])
@RolesPlugin.blueprint.route("/roles/<role_name>", methods=["GET"])
@login_required()
def get_role(role_name, current_session):
"""Get role by name
@ -97,7 +97,7 @@ def get_role(role_name, current_session):
return jsonify(role)
@roles_bp.route("/roles/<int:role_id>", methods=["PUT"])
@RolesPlugin.blueprint.route("/roles/<int:role_id>", methods=["PUT"])
@login_required(permission=_permission_edit)
def edit_role(role_id, current_session):
"""Edit role, rename and / or set permissions
@ -123,7 +123,7 @@ def edit_role(role_id, current_session):
return "", NO_CONTENT
@roles_bp.route("/roles/<int:role_id>", methods=["DELETE"])
@RolesPlugin.blueprint.route("/roles/<int:role_id>", methods=["DELETE"])
@login_required(permission=_permission_delete)
def delete_role(role_id, current_session):
"""Delete role

View File

@ -13,18 +13,17 @@ from flaschengeist.plugins import Plugin
from flaschengeist.models.user import User, _Avatar
from flaschengeist.utils.decorators import login_required, extract_session, headers
from flaschengeist.controller import userController
from flaschengeist.utils.HTTP import created
from flaschengeist.utils.HTTP import created, no_content
from flaschengeist.utils.datetime import from_iso_format
users_bp = Blueprint("users", __name__)
class UsersPlugin(Plugin):
def __init__(self, cfg):
super().__init__(blueprint=users_bp, permissions=permissions.permissions)
name = "users"
blueprint = Blueprint(name, __name__)
permissions = permissions.permissions
@users_bp.route("/users", methods=["POST"])
@UsersPlugin.blueprint.route("/users", methods=["POST"])
def register():
"""Register a new user
The password will be set to a random string of at lease 16byte entropy.
@ -55,7 +54,7 @@ def register():
return make_response(jsonify(userController.register(data)), CREATED)
@users_bp.route("/users", methods=["GET"])
@UsersPlugin.blueprint.route("/users", methods=["GET"])
@login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def list_users(current_session):
@ -74,7 +73,7 @@ def list_users(current_session):
return jsonify(users)
@users_bp.route("/users/<userid>", methods=["GET"])
@UsersPlugin.blueprint.route("/users/<userid>", methods=["GET"])
@login_required()
@headers({"Cache-Control": "private, must-revalidate, max-age=3600"})
def get_user(userid, current_session):
@ -97,7 +96,25 @@ def get_user(userid, current_session):
return jsonify(serial)
@users_bp.route("/users/<userid>/avatar", methods=["GET"])
@UsersPlugin.blueprint.route("/users/<userid>/frontend", methods=["POST", "GET"])
@login_required()
def frontend(userid, current_session):
if current_session.user_.userid != userid:
raise Forbidden
if request.method == "POST":
if request.content_length > 1024 ** 2:
raise BadRequest
current_session.user_.set_attribute("frontend", request.get_json())
return no_content()
else:
content = current_session.user_.get_attribute("frontend", None)
if content is None:
return no_content()
return jsonify(content)
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["GET"])
@headers({"Cache-Control": "public, max-age=604800"})
def get_avatar(userid):
user = userController.get_user(userid)
@ -109,7 +126,7 @@ def get_avatar(userid):
raise NotFound
@users_bp.route("/users/<userid>/avatar", methods=["POST"])
@UsersPlugin.blueprint.route("/users/<userid>/avatar", methods=["POST"])
@login_required()
def set_avatar(userid, current_session):
user = userController.get_user(userid)
@ -127,7 +144,7 @@ def set_avatar(userid, current_session):
raise BadRequest
@users_bp.route("/users/<userid>", methods=["DELETE"])
@UsersPlugin.blueprint.route("/users/<userid>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_user(userid, current_session):
"""Delete user by userid
@ -147,7 +164,7 @@ def delete_user(userid, current_session):
return "", NO_CONTENT
@users_bp.route("/users/<userid>", methods=["PUT"])
@UsersPlugin.blueprint.route("/users/<userid>", methods=["PUT"])
@login_required()
def edit_user(userid, current_session):
"""Modify user by userid
@ -198,3 +215,19 @@ def edit_user(userid, current_session):
userController.modify_user(user, password, new_password)
userController.update_user(user)
return "", NO_CONTENT
@UsersPlugin.blueprint.route("/notifications", methods=["GET"])
@login_required()
def notifications(current_session):
f = request.args.get("from", None)
if f is not None:
f = from_iso_format(f)
return jsonify(userController.get_notifications(current_session.user_, f))
@UsersPlugin.blueprint.route("/notifications/<nid>", methods=["DELETE"])
@login_required()
def remove_notifications(nid, current_session):
userController.delete_notification(nid, current_session.user_)
return no_content()

View File

@ -0,0 +1,17 @@
from flask import current_app
from flaschengeist.utils.HTTP import no_content
_scheduled = set()
def scheduled(func):
_scheduled.add(func)
return func
@current_app.route("/cron")
def __run_scheduled():
for function in _scheduled:
function()
return no_content()

View File

@ -160,7 +160,7 @@ def export(arguments):
if arguments.plugins:
for entry_point in pkg_resources.iter_entry_points("flaschengeist.plugin"):
plg = entry_point.load()
if hasattr(plg, "models"):
if hasattr(plg, "models") and plg.models is not None:
gen.run(plg.models)
gen.write()

View File

@ -6,7 +6,7 @@ mysql_driver = "PyMySQL" if os.name == "nt" else "mysqlclient"
class DocsCommand(Command):
description = "Generate and export API documentation"
description = "Generate and export API documentation using pdoc3"
user_options = [
# The format is (long option, short option, description).
("output=", "o", "Documentation output path"),