[Plugin] Added balance plugin

Extends the users plugin.

* Users can have an account with a balance
* Users can send from them to others
* Admins can set the balance
* Admins can set limits
This commit is contained in:
Ferdinand Thiessen 2020-10-30 22:15:37 +01:00
parent e0d3b211bb
commit 32783041d8
5 changed files with 291 additions and 4 deletions

View File

@ -4,7 +4,7 @@ auth = "auth_plain"
# Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn # Enable if you run flaschengeist behind a proxy, e.g. nginx + gunicorn
#proxy = false #proxy = false
# Set root path, prefixes all routes # Set root path, prefixes all routes
#root = / #root = /api
# Set secret key # Set secret key
secret_key = "V3ryS3cr3t" secret_key = "V3ryS3cr3t"
@ -45,8 +45,8 @@ enabled = true
# SSL or STARTLS # SSL or STARTLS
# CRYPT = SSL # CRYPT = SSL
[balance]
enabled = true
[geruecht] [geruecht]
enabled = false enabled = false
[schubu]
enabled = false

View File

@ -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/<userid>/balance/limit", methods=["GET"])
@login_required()
def get_limit(userid, current_session: Session):
"""Get set limit of an user
Route: ``/users/<userid>/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/<userid>/balance/limit", methods=["PUT"])
@login_required()
def set_limit(userid, current_session: Session):
"""Set the limit of an user
Route: ``/users/<userid>/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/<userid>/balance", methods=["GET"])
@login_required()
def get_balance(userid, current_session: Session):
"""Get balance of user, optionally filtered
Route: ``/users/<userid>/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/<userid>/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/<userid>/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

View File

@ -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)

View File

@ -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)

View File

@ -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("_")]