[Plugin] balance: Added reverting feature

This commit is contained in:
Ferdinand Thiessen 2020-11-18 02:55:31 +01:00
parent 737dd9d5cf
commit 57930837ac
5 changed files with 97 additions and 31 deletions

View File

@ -3,17 +3,17 @@
Extends users plugin with balance functions Extends users plugin with balance functions
""" """
from http.client import NO_CONTENT
from datetime import datetime, timezone from datetime import datetime, timezone
from flask import Blueprint, jsonify, request from flask import Blueprint, request
from werkzeug.exceptions import Forbidden, BadRequest from werkzeug.exceptions import Forbidden, BadRequest
from flaschengeist import logger from flaschengeist import logger
from flaschengeist.utils import HTTP
from flaschengeist.models.session import Session from flaschengeist.models.session import Session
from flaschengeist.utils.datetime import from_iso_format from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.decorator import login_required from flaschengeist.decorator import login_required
from flaschengeist.controller import userController 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 from . import balance_controller, permissions, models
@ -26,7 +26,7 @@ class BalancePlugin(Plugin):
def __init__(self, config): def __init__(self, config):
super().__init__(blueprint=balance_bp, permissions=permissions.permissions) super().__init__(blueprint=balance_bp, permissions=permissions.permissions)
@update_user_hook @before_update_user
def set_default_limit(user): def set_default_limit(user):
if "limit" in config: if "limit" in config:
limit = config["limit"] limit = config["limit"]
@ -59,7 +59,7 @@ def get_limit(userid, current_session: Session):
): ):
raise Forbidden raise Forbidden
return jsonify({"limit": balance_controller.get_limit(user)}) return {"limit": balance_controller.get_limit(user)}
@balance_bp.route("/users/<userid>/balance/limit", methods=["PUT"]) @balance_bp.route("/users/<userid>/balance/limit", methods=["PUT"])
@ -88,7 +88,7 @@ def set_limit(userid, current_session: Session):
except (TypeError, KeyError): except (TypeError, KeyError):
raise BadRequest raise BadRequest
balance_controller.set_limit(user, limit) balance_controller.set_limit(user, limit)
return "", NO_CONTENT return HTTP.no_content()
@balance_bp.route("/users/<userid>/balance", methods=["GET"]) @balance_bp.route("/users/<userid>/balance", methods=["GET"])
@ -125,8 +125,8 @@ def get_balance(userid, current_session: Session):
else: else:
end = datetime.now(tz=timezone.utc) end = datetime.now(tz=timezone.utc)
balance = balance_controller.get(user, start, end) balance = balance_controller.get_balance(user, start, end)
return jsonify({"credit": balance[0], "debit": balance[1], "balance": balance[2]}) return {"credit": balance[0], "debit": balance[1], "balance": balance[2]}
@balance_bp.route("/users/<userid>/balance", methods=["PUT"]) @balance_bp.route("/users/<userid>/balance", methods=["PUT"])
@ -144,7 +144,7 @@ def change_balance(userid, current_session: Session):
current_session: Session sent with Authorization Header current_session: Session sent with Authorization Header
Returns: Returns:
JSON object containing credit, debit and balance or HTTP error JSON encoded transaction (201) or HTTP error
""" """
data = request.get_json() 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 ( 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) sender != current_session._user and current_session._user.has_permission(permissions.SEND_OTHER)
): ):
balance_controller.send(sender, user, data["amount"], current_session._user) return HTTP.created(balance_controller.send(sender, user, data["amount"], current_session._user))
return "", NO_CONTENT
elif ( elif (
amount < 0 amount < 0
@ -174,7 +173,31 @@ def change_balance(userid, current_session: Session):
or current_session._user.has_permission(permissions.DEBIT) or current_session._user.has_permission(permissions.DEBIT)
) )
) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)): ) or (amount > 0 and current_session._user.has_permission(permissions.CREDIT)):
balance_controller.change_balance(user, data["amount"], current_session._user) return HTTP.created(balance_controller.change_balance(user, data["amount"], current_session._user))
return "", NO_CONTENT
raise Forbidden raise Forbidden
@balance_bp.route("/balance/<int:transaction_id>", methods=["DELETE"])
@login_required()
def reverse_transaction(transaction_id, current_session: Session):
"""Reverse a transaction
Route: ``/balance/<int:transaction_id>`` | Method: ``DELETE``
Args:
transaction_id: Identifier of the transaction
current_session: Session sent with Authorization Header
Returns:
JSON encoded reversal (transaction) (201) or HTTP error
"""
transaction = balance_controller.get_transaction(transaction_id)
if current_session._user.has_permission(permissions.REVERSAL) or (
transaction.sender_ == current_session._user
and (datetime.now(tz=timezone.utc) - transaction.time).total_seconds() < 10
):
reversal = balance_controller.reverse_transaction(transaction, current_session._user)
return HTTP.created(reversal)
raise Forbidden

View File

@ -5,13 +5,14 @@
from sqlalchemy import func from sqlalchemy import func
from datetime import datetime, timezone from datetime import datetime, timezone
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest, NotFound
from flaschengeist.database import db from flaschengeist.database import db
from flaschengeist.models.user import User from flaschengeist.models.user import User
from .models import Transaction from .models import Transaction
from . import permissions from . import permissions
from ... import logger
__attribute_limit = "balance_limit" __attribute_limit = "balance_limit"
@ -26,7 +27,7 @@ def get_limit(user: User) -> float:
return user.get_attribute(__attribute_limit, default=None) 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: if not start:
start = datetime.fromtimestamp(0, tz=timezone.utc) start = datetime.fromtimestamp(0, tz=timezone.utc)
if not end: if not end:
@ -35,6 +36,7 @@ def get(user, start: datetime = None, end: datetime = None):
credit = ( credit = (
db.session.query(func.sum(Transaction.amount)) db.session.query(func.sum(Transaction.amount))
.filter(Transaction.receiver_ == user) .filter(Transaction.receiver_ == user)
.filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions
.filter(start <= Transaction.time) .filter(start <= Transaction.time)
.filter(Transaction.time <= end) .filter(Transaction.time <= end)
.scalar() .scalar()
@ -43,6 +45,7 @@ def get(user, start: datetime = None, end: datetime = None):
debit = ( debit = (
db.session.query(func.sum(Transaction.amount)) db.session.query(func.sum(Transaction.amount))
.filter(Transaction.sender_ == user) .filter(Transaction.sender_ == user)
.filter(Transaction._reversal_id.is_(None)) # ignore reverted transactions
.filter(start <= Transaction.time) .filter(start <= Transaction.time)
.filter(Transaction.time <= end) .filter(Transaction.time <= end)
.scalar() .scalar()
@ -59,13 +62,15 @@ def send(sender: User, receiver, amount: float, author: User):
receiver: User who receives the amount receiver: User who receives the amount
amount: Amount to send amount: Amount to send
author: User authoring this transaction author: User authoring this transaction
Returns:
Transaction that was created
Raises: Raises:
BadRequest if amount <= 0 BadRequest if amount <= 0
""" """
if amount <= 0: if amount <= 0:
raise BadRequest 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( if (get(sender)[2] - amount) < sender.get_attribute(__attribute_limit) and not author.has_permission(
permissions.EXCEED_LIMIT 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) transaction = Transaction(sender_=sender, receiver_=receiver, amount=amount, author_=author)
db.session.add(transaction) db.session.add(transaction)
db.session.commit() db.session.commit()
return transaction
def change_balance(user, amount: float, author): 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 sender = user if amount < 0 else None
receiver = 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

View File

@ -1,4 +1,5 @@
from datetime import datetime, timezone from datetime import datetime
from typing import Optional
from flaschengeist.models.user import User from flaschengeist.models.user import User
@ -8,19 +9,22 @@ from flaschengeist.models import ModelSerializeMixin, UtcDateTime
class Transaction(db.Model, ModelSerializeMixin): class Transaction(db.Model, ModelSerializeMixin):
__tablename__ = "balance_transaction" __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 # Protected foreign key properties
_receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id")) _receiver_id = db.Column("receiver_id", db.Integer, db.ForeignKey("user.id"))
_sender_id = db.Column("sender_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) _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 # Not exported relationships just in backend only
sender_: User = db.relationship("User", foreign_keys=[_sender_id]) sender_: User = db.relationship("User", foreign_keys=[_sender_id])
@ -29,12 +33,21 @@ class Transaction(db.Model, ModelSerializeMixin):
@property @property
def sender_id(self): def sender_id(self):
return self.sender_.userid return self.sender_.userid if self.sender_ else None
@property @property
def receiver_id(self): def receiver_id(self):
return self.receiver_.userid return self.receiver_.userid if self.receiver_ else None
@property @property
def author_id(self): def author_id(self):
return self.author_.userid 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

View File

@ -21,4 +21,7 @@ SET_LIMIT = "balance_set_limit"
EXCEED_LIMIT = "balance_exceed_limit" EXCEED_LIMIT = "balance_exceed_limit"
"""Allow sending / sub while exceeding the set 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("_")] permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -7,7 +7,7 @@ from flaschengeist.models.user import User
from flaschengeist.controller import userController from flaschengeist.controller import userController
from flaschengeist.controller.messageController import Message from flaschengeist.controller.messageController import Message
from . import Plugin, send_message_hook from . import Plugin
class MailMessagePlugin(Plugin): class MailMessagePlugin(Plugin):
@ -20,7 +20,7 @@ class MailMessagePlugin(Plugin):
self.crypt = config["CRYPT"] self.crypt = config["CRYPT"]
self.mail = config["MAIL"] self.mail = config["MAIL"]
@send_message_hook # @send_message_hook
def dummy_send(msg): def dummy_send(msg):
self.send_mail(msg) self.send_mail(msg)