From 57930837ac444f536b176cca7c6e56f33176bb1c Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Wed, 18 Nov 2020 02:55:31 +0100 Subject: [PATCH] [Plugin] balance: Added reverting feature --- flaschengeist/plugins/balance/__init__.py | 49 ++++++++++++++----- .../plugins/balance/balance_controller.py | 35 +++++++++++-- flaschengeist/plugins/balance/models.py | 37 +++++++++----- flaschengeist/plugins/balance/permissions.py | 3 ++ flaschengeist/plugins/message_mail.py | 4 +- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/flaschengeist/plugins/balance/__init__.py b/flaschengeist/plugins/balance/__init__.py index 862cdad..425feba 100644 --- a/flaschengeist/plugins/balance/__init__.py +++ b/flaschengeist/plugins/balance/__init__.py @@ -3,17 +3,17 @@ Extends users plugin with balance functions """ -from http.client import NO_CONTENT from datetime import datetime, timezone -from flask import Blueprint, jsonify, request +from flask import Blueprint, request from werkzeug.exceptions import Forbidden, BadRequest 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.decorator import login_required from flaschengeist.controller import userController -from flaschengeist.plugins import Plugin, update_user_hook +from flaschengeist.plugins import Plugin, before_update_user from . import balance_controller, permissions, models @@ -26,7 +26,7 @@ class BalancePlugin(Plugin): def __init__(self, config): super().__init__(blueprint=balance_bp, permissions=permissions.permissions) - @update_user_hook + @before_update_user def set_default_limit(user): if "limit" in config: limit = config["limit"] @@ -59,7 +59,7 @@ def get_limit(userid, current_session: Session): ): raise Forbidden - return jsonify({"limit": balance_controller.get_limit(user)}) + return {"limit": balance_controller.get_limit(user)} @balance_bp.route("/users//balance/limit", methods=["PUT"]) @@ -88,7 +88,7 @@ def set_limit(userid, current_session: Session): except (TypeError, KeyError): raise BadRequest balance_controller.set_limit(user, limit) - return "", NO_CONTENT + return HTTP.no_content() @balance_bp.route("/users//balance", methods=["GET"]) @@ -125,8 +125,8 @@ def get_balance(userid, current_session: Session): 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 = balance_controller.get_balance(user, start, end) + return {"credit": balance[0], "debit": balance[1], "balance": balance[2]} @balance_bp.route("/users//balance", methods=["PUT"]) @@ -144,7 +144,7 @@ def change_balance(userid, current_session: Session): current_session: Session sent with Authorization Header Returns: - JSON object containing credit, debit and balance or HTTP error + JSON encoded transaction (201) or HTTP error """ data = request.get_json() @@ -164,8 +164,7 @@ def change_balance(userid, current_session: Session): 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) ): - balance_controller.send(sender, user, data["amount"], current_session._user) - return "", NO_CONTENT + return HTTP.created(balance_controller.send(sender, user, data["amount"], current_session._user)) elif ( amount < 0 @@ -174,7 +173,31 @@ def change_balance(userid, current_session: Session): or current_session._user.has_permission(permissions.DEBIT) ) ) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)): - balance_controller.change_balance(user, data["amount"], current_session._user) - return "", NO_CONTENT + 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 diff --git a/flaschengeist/plugins/balance/balance_controller.py b/flaschengeist/plugins/balance/balance_controller.py index 596f9ce..57925f6 100644 --- a/flaschengeist/plugins/balance/balance_controller.py +++ b/flaschengeist/plugins/balance/balance_controller.py @@ -5,13 +5,14 @@ from sqlalchemy import func from datetime import datetime, timezone -from werkzeug.exceptions import BadRequest +from werkzeug.exceptions import BadRequest, NotFound from flaschengeist.database import db from flaschengeist.models.user import User from .models import Transaction from . import permissions +from ... import logger __attribute_limit = "balance_limit" @@ -26,7 +27,7 @@ def get_limit(user: User) -> float: return user.get_attribute(__attribute_limit, default=None) -def get(user, start: datetime = None, end: datetime = None): +def get_balance(user, start: datetime = None, end: datetime = None): if not start: start = datetime.fromtimestamp(0, tz=timezone.utc) if not end: @@ -35,6 +36,7 @@ def get(user, start: datetime = None, end: datetime = None): credit = ( db.session.query(func.sum(Transaction.amount)) .filter(Transaction.receiver_ == user) + .filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() @@ -43,6 +45,7 @@ def get(user, start: datetime = None, end: datetime = None): debit = ( db.session.query(func.sum(Transaction.amount)) .filter(Transaction.sender_ == user) + .filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions .filter(start <= Transaction.time) .filter(Transaction.time <= end) .scalar() @@ -59,13 +62,15 @@ def send(sender: User, receiver, amount: float, author: User): receiver: User who receives the amount amount: Amount to send author: User authoring this transaction + Returns: + Transaction that was created Raises: BadRequest if amount <= 0 """ if amount <= 0: raise BadRequest - if sender.has_attribute(__attribute_limit): + if sender and sender.has_attribute(__attribute_limit): if (get(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission( permissions.EXCEED_LIMIT ): @@ -74,6 +79,7 @@ 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() + return transaction def change_balance(user, amount: float, author): @@ -86,4 +92,25 @@ def change_balance(user, amount: float, author): """ sender = user if amount < 0 else None receiver = user if amount > 0 else None - send(sender, receiver, abs(amount), author) + return send(sender, receiver, abs(amount), author) + + +def get_transaction(transaction_id) -> Transaction: + transaction = Transaction.query.get(transaction_id) + if not transaction: + raise NotFound + return transaction + + +def reverse_transaction(transaction: Transaction, author: User): + """Reverse a transaction + + Args: + transaction: Transaction to reverse + author: User that wants the transaction to be reverted + """ + reversal = send(transaction.receiver_, transaction.sender_, transaction.amount, author) + reversal.reversal = transaction + transaction.reversal = reversal + db.session.commit() + return reversal diff --git a/flaschengeist/plugins/balance/models.py b/flaschengeist/plugins/balance/models.py index 5219751..8df224c 100644 --- a/flaschengeist/plugins/balance/models.py +++ b/flaschengeist/plugins/balance/models.py @@ -1,4 +1,5 @@ -from datetime import datetime, timezone +from datetime import datetime +from typing import Optional from flaschengeist.models.user import User @@ -8,19 +9,22 @@ from flaschengeist.models import ModelSerializeMixin, UtcDateTime class Transaction(db.Model, ModelSerializeMixin): __tablename__ = "balance_transaction" - id: int = db.Column("id", db.Integer, primary_key=True) - 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) - - # Dummy properties used for JSON serialization (userid instead of full user) - sender_id: str = "" - receiver_id: str = "" - author_id: str = "" - # 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) + _reversal_id = db.Column("reversal_id", db.Integer, db.ForeignKey("balance_transaction.id")) + + # Public and exported member + id: int = db.Column("id", db.Integer, 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: Optional["Transaction"] = db.relationship("Transaction", foreign_keys=[_reversal_id]) + + # Dummy properties used for JSON serialization (userid instead of full user) + sender_id: Optional[str] = "" + receiver_id: Optional[str] = "" + author_id: Optional[str] = "" # Not exported relationships just in backend only sender_: User = db.relationship("User", foreign_keys=[_sender_id]) @@ -29,12 +33,21 @@ class Transaction(db.Model, ModelSerializeMixin): @property def sender_id(self): - return self.sender_.userid + return self.sender_.userid if self.sender_ else None @property def receiver_id(self): - return self.receiver_.userid + return self.receiver_.userid if self.receiver_ else None @property def author_id(self): return self.author_.userid + + # Override to prevent circular dependencies (endless JSON) + def serialize(self): + d = super().serialize() + if d["reversal"]: + d["reversal"].reversal = None + else: + d["reversal"] = None + return d diff --git a/flaschengeist/plugins/balance/permissions.py b/flaschengeist/plugins/balance/permissions.py index 713d204..feb0c81 100644 --- a/flaschengeist/plugins/balance/permissions.py +++ b/flaschengeist/plugins/balance/permissions.py @@ -21,4 +21,7 @@ SET_LIMIT = "balance_set_limit" EXCEED_LIMIT = "balance_exceed_limit" """Allow sending / sub while exceeding the set limit""" +REVERSAL = "balance_reversal" +"""Allow reverting transactions""" + permissions = [value for key, value in globals().items() if not key.startswith("_")] diff --git a/flaschengeist/plugins/message_mail.py b/flaschengeist/plugins/message_mail.py index b3f34b7..9e6875f 100644 --- a/flaschengeist/plugins/message_mail.py +++ b/flaschengeist/plugins/message_mail.py @@ -7,7 +7,7 @@ from flaschengeist.models.user import User from flaschengeist.controller import userController from flaschengeist.controller.messageController import Message -from . import Plugin, send_message_hook +from . import Plugin class MailMessagePlugin(Plugin): @@ -20,7 +20,7 @@ class MailMessagePlugin(Plugin): self.crypt = config["CRYPT"] self.mail = config["MAIL"] - @send_message_hook + # @send_message_hook def dummy_send(msg): self.send_mail(msg)