[Plugin] balance: Added reverting feature
This commit is contained in:
parent
737dd9d5cf
commit
57930837ac
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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("_")]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue