diff --git a/flaschengeist/app.py b/flaschengeist/app.py index 2bdb2bb..e91f000 100644 --- a/flaschengeist/app.py +++ b/flaschengeist/app.py @@ -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( diff --git a/flaschengeist/controller/userController.py b/flaschengeist/controller/userController.py index a84b795..2305f9a 100644 --- a/flaschengeist/controller/userController.py +++ b/flaschengeist/controller/userController.py @@ -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() diff --git a/flaschengeist/models/__init__.py b/flaschengeist/models/__init__.py index 5cee5bf..f63d173 100644 --- a/flaschengeist/models/__init__.py +++ b/flaschengeist/models/__init__.py @@ -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: diff --git a/flaschengeist/models/notification.py b/flaschengeist/models/notification.py new file mode 100644 index 0000000..574b089 --- /dev/null +++ b/flaschengeist/models/notification.py @@ -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") diff --git a/flaschengeist/models/session.py b/flaschengeist/models/session.py index 7822804..9acf27c 100644 --- a/flaschengeist/models/session.py +++ b/flaschengeist/models/session.py @@ -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 diff --git a/flaschengeist/models/setting.py b/flaschengeist/models/setting.py index 6e8f74d..277f36c 100644 --- a/flaschengeist/models/setting.py +++ b/flaschengeist/models/setting.py @@ -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)) diff --git a/flaschengeist/models/user.py b/flaschengeist/models/user.py index 4118120..43ef91e 100644 --- a/flaschengeist/models/user.py +++ b/flaschengeist/models/user.py @@ -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) diff --git a/flaschengeist/plugins/__init__.py b/flaschengeist/plugins/__init__.py index 6665830..094eae7 100644 --- a/flaschengeist/plugins/__init__.py +++ b/flaschengeist/plugins/__init__.py @@ -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): diff --git a/flaschengeist/plugins/auth/__init__.py b/flaschengeist/plugins/auth/__init__.py index f2289f0..3874f79 100644 --- a/flaschengeist/plugins/auth/__init__.py +++ b/flaschengeist/plugins/auth/__init__.py @@ -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/", methods=["DELETE"]) +@AuthRoutePlugin.blueprint.route("/auth/", 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/", methods=["GET"]) +@AuthRoutePlugin.blueprint.route("/auth/", 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/", methods=["PUT"]) +@AuthRoutePlugin.blueprint.route("/auth/", 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//user", methods=["GET"]) +@AuthRoutePlugin.blueprint.route("/auth//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: diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index fc424bb..a7558f9 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -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//balance/shortcuts", methods=["GET", "PUT"]) -@login_required() -def get_shortcuts(userid, current_session: Session): - """Get balance shortcuts of an user - - Route: ``/users//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//balance/limit", methods=["GET"]) -@login_required() -def get_limit(userid, current_session: Session): - """Get limit of an user - - Route: ``/users//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//balance/limit", methods=["PUT"]) -@login_required(permissions.SET_LIMIT) -def set_limit(userid, current_session: Session): - """Set the limit of an user - - Route: ``/users//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//balance", methods=["GET"]) -@login_required(permission=permissions.SHOW) -def get_balance(userid, current_session: Session): - """Get balance of user, optionally filtered - - Route: ``/users//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//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//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//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//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/", methods=["DELETE"]) -@login_required() -def reverse_transaction(transaction_id, current_session: Session): - """Reverse a transaction - - Route: ``/balance/`` | 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()]) diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index ea4d499..0ab282f 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -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 diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index be504aa..23bb32c 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -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 diff --git a/flaschengeist/plugins/balance/routes.py b/flaschengeist/plugins/balance/routes.py new file mode 100644 index 0000000..d28f913 --- /dev/null +++ b/flaschengeist/plugins/balance/routes.py @@ -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//balance/shortcuts", methods=["GET", "PUT"]) +@login_required() +def get_shortcuts(userid, current_session: Session): + """Get balance shortcuts of an user + + Route: ``/users//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//balance/limit", methods=["GET"]) +@login_required() +def get_limit(userid, current_session: Session): + """Get limit of an user + + Route: ``/users//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//balance/limit", methods=["PUT"]) +@login_required(permissions.SET_LIMIT) +def set_limit(userid, current_session: Session): + """Set the limit of an user + + Route: ``/users//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//balance", methods=["GET"]) +@login_required(permission=permissions.SHOW) +def get_balance(userid, current_session: Session): + """Get balance of user, optionally filtered + + Route: ``/users//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//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//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//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//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/", methods=["DELETE"]) +@login_required() +def reverse_transaction(transaction_id, current_session: Session): + """Reverse a transaction + + Route: ``/balance/`` | 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()]) diff --git a/flaschengeist/plugins/events/__init__.py b/flaschengeist/plugins/events/__init__.py index b0c7b98..e4a0af0 100644 --- a/flaschengeist/plugins/events/__init__.py +++ b/flaschengeist/plugins/events/__init__.py @@ -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/", 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/`` | 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/", 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/`` | 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/", 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/`` | 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/", methods=["GET"]) -@login_required() -def get_event(event_id, current_session): - """Get event by id - - Route: ``/events/`` | 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//", methods=["GET"]) -@events_bp.route("/events///", 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[//[/]]`` | 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/", methods=["PUT"]) -@login_required(permission=permissions.EDIT) -def modify_event(event_id, current_session): - """Modify an event - - Route: ``/events/`` | 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/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def delete_event(event_id, current_session): - """Delete an event - - Route: ``/events/`` | 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//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//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//jobs/", methods=["DELETE"]) -@login_required(permission=permissions.DELETE) -def delete_job(event_id, job_id, current_session): - """Delete a Job - - Route: ``/events//jobs/`` | 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//jobs/", methods=["PUT"]) -@login_required() -def update_job(event_id, job_id, current_session: Session): - """Edit Job or assign user to the Job - - Route: ``/events//jobs/`` | 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 diff --git a/flaschengeist/plugins/events/event_controller.py b/flaschengeist/plugins/events/event_controller.py index ee1f663..31a3b7b 100644 --- a/flaschengeist/plugins/events/event_controller.py +++ b/flaschengeist/plugins/events/event_controller.py @@ -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() diff --git a/flaschengeist/plugins/events/models.py b/flaschengeist/plugins/events/models.py index 7479df0..daf308a 100644 --- a/flaschengeist/plugins/events/models.py +++ b/flaschengeist/plugins/events/models.py @@ -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): diff --git a/flaschengeist/plugins/events/permissions.py b/flaschengeist/plugins/events/permissions.py index f889793..459967c 100644 --- a/flaschengeist/plugins/events/permissions.py +++ b/flaschengeist/plugins/events/permissions.py @@ -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("_")] diff --git a/flaschengeist/plugins/events/routes.py b/flaschengeist/plugins/events/routes.py new file mode 100644 index 0000000..7fccccf --- /dev/null +++ b/flaschengeist/plugins/events/routes.py @@ -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/", 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/`` | 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/", 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/`` | 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/", 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/`` | 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/", methods=["GET"]) +@login_required() +def get_event(event_id, current_session): + """Get event by id + + Route: ``/events/`` | 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//", methods=["GET"]) +@EventPlugin.blueprint.route("/events///", 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[//[/]]`` | 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/", methods=["PUT"]) +@login_required(permission=permissions.EDIT) +def modify_event(event_id, current_session): + """Modify an event + + Route: ``/events/`` | 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/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_event(event_id, current_session): + """Delete an event + + Route: ``/events/`` | 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//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//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//jobs/", methods=["DELETE"]) +@login_required(permission=permissions.DELETE) +def delete_job(event_id, job_id, current_session): + """Delete a Job + + Route: ``/events//jobs/`` | 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//jobs/", methods=["PUT"]) +@login_required() +def update_job(event_id, job_id, current_session: Session): + """Edit Job or assign user to the Job + + Route: ``/events//jobs/`` | 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 diff --git a/flaschengeist/plugins/pricelist/__init__.py b/flaschengeist/plugins/pricelist/__init__.py index 58c1ba8..35c3ce1 100644 --- a/flaschengeist/plugins/pricelist/__init__.py +++ b/flaschengeist/plugins/pricelist/__init__.py @@ -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.decorators import login_required, extract_session +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/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drink-types", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drink-types/", 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/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/drink-types/", 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/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/drink-types/", 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/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/tags", methods=["GET"]) +@PriceListPlugin.blueprint.route("/tags/", 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/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/tags/", 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/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/tags/", 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/", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks", methods=["GET"]) +@PriceListPlugin.blueprint.route("/drinks/", 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/", methods=["GET"]) + +@PriceListPlugin.blueprint.route("/drinks/search/", 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/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["PUT"]) def update_drink(identifier): data = request.get_json() return jsonify(pricelist_controller.update_drink(identifier, data)) -@pricelist_bp.route("/drinks/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/drinks/", methods=["DELETE"]) def delete_drink(identifier): pricelist_controller.delete_drink(identifier) - return "", NO_CONTENT + return no_content() -@pricelist_bp.route("/prices/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/prices/", methods=["DELETE"]) def delete_price(identifier): pricelist_controller.delete_price(identifier) - return "", NO_CONTENT + return no_content() -@pricelist_bp.route("/volumes/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/volumes/", 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/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/ingredients/", 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/", methods=["PUT"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", 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/", methods=["DELETE"]) +@PriceListPlugin.blueprint.route("/ingredients/extraIngredients/", 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//pricecalc_columns", methods=["GET", "PUT"]) +@PriceListPlugin.blueprint.route("/users//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//picture", methods=["POST", "GET", "DELETE"]) +@PriceListPlugin.route("/drinks//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/", methods=["GET"]) +@PriceListPlugin.route("/picture/", 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) \ No newline at end of file diff --git a/flaschengeist/plugins/pricelist/models.py b/flaschengeist/plugins/pricelist/models.py index b547f34..d392168 100644 --- a/flaschengeist/plugins/pricelist/models.py +++ b/flaschengeist/plugins/pricelist/models.py @@ -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) diff --git a/flaschengeist/plugins/pricelist/pricelist_controller.py b/flaschengeist/plugins/pricelist/pricelist_controller.py index 925a1b2..6a115f5 100644 --- a/flaschengeist/plugins/pricelist/pricelist_controller.py +++ b/flaschengeist/plugins/pricelist/pricelist_controller.py @@ -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: - if public: - return _create_public_drink(drink) - return drink - raise NotFound + raise BadRequest("Invalid identifier type for Drink") + if drink is None: + raise NotFound + if public: + return _create_public_drink(drink) + return drink def set_drink(data): @@ -151,44 +149,40 @@ def set_drink(data): def update_drink(identifier, data): - allowedKeys = Drink().serialize().keys() - if "id" in data: - data.pop("id") - if "volumes" in data: - volumes = data.pop("volumes") - 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")) - 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) + try: + if "id" in data: + data.pop("id") + volumes = data.pop("volumes") if "volumes" in data else None + if "tags" in data: + data.pop("tags") + 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) + 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: - set_volumes(volumes, drink) - db.session.commit() - return drink + 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(): diff --git a/flaschengeist/plugins/roles/__init__.py b/flaschengeist/plugins/roles/__init__.py index 8ad1a35..54b0547 100644 --- a/flaschengeist/plugins/roles/__init__.py +++ b/flaschengeist/plugins/roles/__init__.py @@ -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/", methods=["GET"]) +@RolesPlugin.blueprint.route("/roles/", 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/", methods=["PUT"]) +@RolesPlugin.blueprint.route("/roles/", 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/", methods=["DELETE"]) +@RolesPlugin.blueprint.route("/roles/", methods=["DELETE"]) @login_required(permission=_permission_delete) def delete_role(role_id, current_session): """Delete role diff --git a/flaschengeist/plugins/users/__init__.py b/flaschengeist/plugins/users/__init__.py index 277aca1..e5b08a6 100644 --- a/flaschengeist/plugins/users/__init__.py +++ b/flaschengeist/plugins/users/__init__.py @@ -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/", methods=["GET"]) +@UsersPlugin.blueprint.route("/users/", 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//avatar", methods=["GET"]) +@UsersPlugin.blueprint.route("/users//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//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//avatar", methods=["POST"]) +@UsersPlugin.blueprint.route("/users//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/", methods=["DELETE"]) +@UsersPlugin.blueprint.route("/users/", 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/", methods=["PUT"]) +@UsersPlugin.blueprint.route("/users/", 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/", methods=["DELETE"]) +@login_required() +def remove_notifications(nid, current_session): + userController.delete_notification(nid, current_session.user_) + return no_content() diff --git a/flaschengeist/utils/scheduler.py b/flaschengeist/utils/scheduler.py new file mode 100644 index 0000000..aefbddd --- /dev/null +++ b/flaschengeist/utils/scheduler.py @@ -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() diff --git a/run_flaschengeist b/run_flaschengeist index 610b9eb..26c10ec 100644 --- a/run_flaschengeist +++ b/run_flaschengeist @@ -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() diff --git a/setup.py b/setup.py index c1c2847..db12c1a 100644 --- a/setup.py +++ b/setup.py @@ -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"),