diff --git a/flaschengeist.example.toml b/flaschengeist.example.toml index 093a9a8..a804467 100644 --- a/flaschengeist.example.toml +++ b/flaschengeist.example.toml @@ -4,7 +4,7 @@ auth = "auth_plain" # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn #proxy = false # Set root path, prefixes all routes -#root = / +#root = /api # Set secret key secret_key = "V3ryS3cr3t" @@ -45,8 +45,8 @@ enabled = true # SSL or STARTLS # CRYPT = SSL +[balance] +enabled = true + [geruecht] enabled = false - -[schubu] -enabled = false diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py new file mode 100644 index 0000000..b3ddfcb --- /dev/null +++ b/flaschengeist/plugins/balance/__init__.py @@ -0,0 +1,166 @@ +"""Balance plugin + +Extends users plugin with balance functions +""" + +from datetime import datetime, timezone +from flask import Blueprint, jsonify, request +from werkzeug.exceptions import Forbidden, BadRequest +from backports.datetime_fromisoformat import MonkeyPatch + +from flaschengeist.plugins import Plugin +from flaschengeist.models.session import Session +from flaschengeist.decorator import login_required +from flaschengeist.controller import userController + +from . import balance_controller, permissions + +MonkeyPatch.patch_fromisoformat() +balance_bp = Blueprint("balance", __name__) + + +class BalancePlugin(Plugin): + def __init__(self, config): + super().__init__(blueprint=balance_bp, permissions=permissions.permissions) + + def install(self): + from flaschengeist.database import db + from .models import Transaction + + db.create_all() + + +@balance_bp.route("/users//balance/limit", methods=["GET"]) +@login_required() +def get_limit(userid, current_session: Session): + """Get set 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 jsonify({"limit": balance_controller.get_limit(user)}) + + +@balance_bp.route("/users//balance/limit", methods=["PUT"]) +@login_required() +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) + if not current_session._user.has_permission(permissions.SET_LIMIT): + raise Forbidden + + data = request.get_json() + try: + limit = data["limit"] + except (TypeError, KeyError): + raise BadRequest + balance_controller.set_limit(user, limit) + + +@balance_bp.route("/users//balance", methods=["GET"]) +@login_required() +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)) or ( + 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 = datetime.fromisoformat(start).replace(tzinfo=timezone.utc) + else: + start = datetime.fromtimestamp(0, tz=timezone.utc) + + end = request.args.get("to") + if end: + end = datetime.fromisoformat(end).replace(tzinfo=timezone.utc) + else: + end = datetime.now(tz=timezone.utc) + + balance = balance_controller.get(user, start, end) + return jsonify({"credit": balance[0], "debit": balance[1], "balance": balance[2]}) + + +@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 object containing credit, debit and balance 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 balance_controller.send(sender, user, data["amount"], current_session._user) + + elif (amount < 0 and current_session._user.has_permission(permissions.SUB)) or ( + amount > 0 and current_session._user.has_permission(permissions.ADD) + ): + return balance_controller.change_balance(user, data["amount"], current_session._user) + + raise Forbidden diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py new file mode 100644 index 0000000..dd9112c --- /dev/null +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -0,0 +1,82 @@ +from flaschengeist.models.user import User +from sqlalchemy import func +from datetime import datetime + +from werkzeug.exceptions import BadRequest + +from flaschengeist import logger +from flaschengeist.database import db + +from .models import Transaction +from . import permissions + + +__attribute_limit = "balance_limit" + + +def set_limit(user: User, limit: float): + user.set_attribute(__attribute_limit, limit) + db.session.commit() + + +def get_limit(user: User) -> float: + return user.get_attribute(__attribute_limit, default=None) + + +def get(user, start: datetime, end: datetime): + credit = ( + db.session.query(func.sum(Transaction.amount)) + .filter(Transaction.receiver == user) + .filter(start <= Transaction.time) + .filter(Transaction.time <= end) + .scalar() + ) or 0 + logger.debug(credit) + if credit is None: + credit = 0 + debit = ( + db.session.query(func.sum(Transaction.amount)) + .filter(Transaction.sender == user and start <= Transaction.time <= end) + .all()[0][0] + ) + if debit is None: + debit = 0 + return credit, debit, credit - debit + + +def send(sender: User, receiver, amount: float, author: User): + """Send credit from one user to an other + + Args: + sender: User who sends the amount + receiver: User who receives the amount + amount: Amount to send + author: User authoring this transaction + Raises: + BadRequest if amount <= 0 + """ + if amount <= 0: + raise BadRequest + + if sender.has_attribute(__attribute_limit): + if (get(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( + permissions.EXCEED_LIMIT + ): + raise BadRequest("Limit exceeded") + + transaction = Transaction(sender=sender, receiver=receiver, amount=amount, author=author) + db.session.add(transaction) + db.session.commit() + + +def change_balance(user, amount: float, author): + """Change balance of user + + Args: + user: User to change balance + amount: Amount to change balance + author: User authoring this transaction + """ + sender = user if amount < 0 else None + receiver = user if amount > 0 else None + send(sender, receiver, abs(amount), author) diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py new file mode 100644 index 0000000..4a967d5 --- /dev/null +++ b/flaschengeist/plugins/balance/models.py @@ -0,0 +1,22 @@ +from datetime import datetime, timezone + +from flaschengeist.models.user import User + +from flaschengeist.database import db +from flaschengeist.models import ModelSerializeMixin, UtcDateTime + + +class Transaction(db.Model, ModelSerializeMixin): + __tablename__ = "balance_transaction" + time: datetime = db.Column(UtcDateTime, nullable=False, default=datetime.now(tz=timezone.utc)) + amount: float = db.Column(db.Numeric(precision=5, scale=2, asdecimal=False), nullable=False) + + _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) + + sender: User = db.relationship("User", foreign_keys=[_sender_id]) + receiver: User = db.relationship("User", foreign_keys=[_receiver_id]) + author: User = db.relationship("User", foreign_keys=[_author_id]) + + _id = db.Column("id", db.Integer, primary_key=True) diff --git a/flaschengeist/plugins/balance/permissions.py b/flaschengeist/plugins/balance/permissions.py new file mode 100644 index 0000000..43a13ee --- /dev/null +++ b/flaschengeist/plugins/balance/permissions.py @@ -0,0 +1,17 @@ +# Show own and others balance +SHOW = "balance_show" +SHOW_OTHER = "balance_show_others" +# Credit balance +ADD = "balance_add" +# Debit balance +SUB = "balance_sub" +# Send from to other +SEND = "balance_send" +# Send from other to another +SEND_OTHER = "balance_send_others" +# Can set limit for users +SET_LIMIT = "balance_set_limit" +# Allow sending / sub while exceeding the set limit +EXCEED_LIMIT = "balance_exceed_limit" + +permissions = [value for key, value in globals().items() if not key.startswith("_")]