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