Write a set of queries to go along with the event sourcing model
This commit is contained in:
+2
-74
@@ -1,79 +1,7 @@
|
||||
import pwd
|
||||
import subprocess
|
||||
import os
|
||||
import pwd
|
||||
import signal
|
||||
|
||||
from sqlalchemy import or_, and_
|
||||
|
||||
from ..models import User, Product
|
||||
|
||||
|
||||
def search_user(string, session, ignorethisflag=None):
|
||||
string = string.lower()
|
||||
exact_match = (
|
||||
session.query(User)
|
||||
.filter(or_(User.name == string, User.card == string, User.rfid == string))
|
||||
.first()
|
||||
)
|
||||
if exact_match:
|
||||
return exact_match
|
||||
user_list = (
|
||||
session.query(User)
|
||||
.filter(
|
||||
or_(
|
||||
User.name.ilike(f"%{string}%"),
|
||||
User.card.ilike(f"%{string}%"),
|
||||
User.rfid.ilike(f"%{string}%"),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return user_list
|
||||
|
||||
|
||||
def search_product(string, session, find_hidden_products=True):
|
||||
if find_hidden_products:
|
||||
exact_match = (
|
||||
session.query(Product)
|
||||
.filter(or_(Product.bar_code == string, Product.name == string))
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
exact_match = (
|
||||
session.query(Product)
|
||||
.filter(
|
||||
or_(
|
||||
Product.bar_code == string,
|
||||
and_(Product.name == string, Product.hidden is False),
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exact_match:
|
||||
return exact_match
|
||||
if find_hidden_products:
|
||||
product_list = (
|
||||
session.query(Product)
|
||||
.filter(
|
||||
or_(
|
||||
Product.bar_code.ilike(f"%{string}%"),
|
||||
Product.name.ilike(f"%{string}%"),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
else:
|
||||
product_list = (
|
||||
session.query(Product)
|
||||
.filter(
|
||||
or_(
|
||||
Product.bar_code.ilike(f"%{string}%"),
|
||||
and_(Product.name.ilike(f"%{string}%"), Product.hidden is False),
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
return product_list
|
||||
import subprocess
|
||||
|
||||
|
||||
def system_user_exists(username):
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from typing import TypeVar
|
||||
from sqlalchemy import BindParameter, literal
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def const(value: T) -> BindParameter[T]:
|
||||
"""
|
||||
Create a constant SQL literal bind parameter.
|
||||
|
||||
This is useful to avoid too many `?` bind parameters in SQL queries,
|
||||
when the input value is known to be safe.
|
||||
"""
|
||||
|
||||
return literal(value, literal_execute=True)
|
||||
|
||||
|
||||
CONST_ZERO: BindParameter[int] = const(0)
|
||||
CONST_ONE: BindParameter[int] = const(1)
|
||||
CONST_TRUE: BindParameter[bool] = const(True)
|
||||
CONST_FALSE: BindParameter[bool] = const(False)
|
||||
CONST_NONE: BindParameter[None] = const(None)
|
||||
@@ -0,0 +1,37 @@
|
||||
__all__ = [
|
||||
# "add_product",
|
||||
# "add_user",
|
||||
"adjust_interest",
|
||||
"adjust_penalty",
|
||||
"current_interest",
|
||||
"current_penalty",
|
||||
"joint_buy_product",
|
||||
"product_owners",
|
||||
"product_owners_log",
|
||||
"product_price",
|
||||
"product_price_log",
|
||||
"product_stock",
|
||||
# "products_owned_by_user",
|
||||
"search_product",
|
||||
"search_user",
|
||||
"transaction_log",
|
||||
"user_balance",
|
||||
"user_balance_log",
|
||||
]
|
||||
|
||||
# from .add_product import add_product
|
||||
# from .add_user import add_user
|
||||
from .adjust_interest import adjust_interest
|
||||
from .adjust_penalty import adjust_penalty
|
||||
from .current_interest import current_interest
|
||||
from .current_penalty import current_penalty
|
||||
from .joint_buy_product import joint_buy_product
|
||||
from .product_owners import product_owners, product_owners_log
|
||||
from .product_price import product_price, product_price_log
|
||||
from .product_stock import product_stock
|
||||
|
||||
# from .products_owned_by_user import products_owned_by_user
|
||||
from .search_product import search_product
|
||||
from .search_user import search_user
|
||||
from .transaction_log import transaction_log
|
||||
from .user_balance import user_balance, user_balance_log
|
||||
@@ -0,0 +1 @@
|
||||
# TODO: implement me
|
||||
@@ -0,0 +1 @@
|
||||
# TODO: implement me
|
||||
@@ -0,0 +1,28 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
|
||||
# TODO: this type of transaction should be password protected.
|
||||
# the password can be set as a string literal in the config file.
|
||||
|
||||
|
||||
def adjust_interest(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
new_interest: int,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
if new_interest < 0:
|
||||
raise ValueError("Interest rate cannot be negative")
|
||||
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
transaction = Transaction.adjust_interest(
|
||||
user_id=user.id,
|
||||
interest_rate_percent=new_interest,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
@@ -0,0 +1,41 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
from dibbler.queries.current_penalty import current_penalty
|
||||
|
||||
# TODO: this type of transaction should be password protected.
|
||||
# the password can be set as a string literal in the config file.
|
||||
|
||||
|
||||
def adjust_penalty(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
new_penalty: int | None = None,
|
||||
new_penalty_multiplier: int | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
if new_penalty is None and new_penalty_multiplier is None:
|
||||
raise ValueError("At least one of new_penalty or new_penalty_multiplier must be provided")
|
||||
|
||||
if new_penalty_multiplier is not None and new_penalty_multiplier < 100:
|
||||
raise ValueError("Penalty multiplier cannot be less than 100%")
|
||||
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if new_penalty is None or new_penalty_multiplier is None:
|
||||
existing_penalty, existing_penalty_multiplier = current_penalty(sql_session)
|
||||
if new_penalty is None:
|
||||
new_penalty = existing_penalty
|
||||
if new_penalty_multiplier is None:
|
||||
new_penalty_multiplier = existing_penalty_multiplier
|
||||
|
||||
transaction = Transaction.adjust_penalty(
|
||||
user_id=user.id,
|
||||
penalty_threshold=new_penalty,
|
||||
penalty_multiplier_percent=new_penalty_multiplier,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
@@ -0,0 +1,25 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, TransactionType
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
|
||||
|
||||
# TODO: add until transaction parameter
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def current_interest(sql_session: Session) -> int:
|
||||
result = sql_session.scalars(
|
||||
select(Transaction)
|
||||
.where(Transaction.type_ == TransactionType.ADJUST_INTEREST)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(1)
|
||||
).one_or_none()
|
||||
|
||||
if result is None:
|
||||
return DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
elif result.interest_rate_percent is None:
|
||||
return DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
else:
|
||||
return result.interest_rate_percent
|
||||
@@ -0,0 +1,29 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, TransactionType
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
)
|
||||
|
||||
|
||||
# TODO: add until transaction parameter
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def current_penalty(sql_session: Session) -> tuple[int, int]:
|
||||
result = sql_session.scalars(
|
||||
select(Transaction)
|
||||
.where(Transaction.type_ == TransactionType.ADJUST_PENALTY)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(1)
|
||||
).one_or_none()
|
||||
|
||||
if result is None:
|
||||
return DEFAULT_PENALTY_THRESHOLD, DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE
|
||||
|
||||
assert result.penalty_threshold is not None, "Penalty threshold must be set"
|
||||
assert result.penalty_multiplier_percent is not None, "Penalty multiplier percent must be set"
|
||||
|
||||
return result.penalty_threshold, result.penalty_multiplier_percent
|
||||
@@ -0,0 +1,56 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
def joint_buy_product(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
product_count: int,
|
||||
instigator: User,
|
||||
users: list[User],
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Create buy product transactions for multiple users at once.
|
||||
"""
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if instigator not in users:
|
||||
raise ValueError("Instigator must be in the list of users buying the product.")
|
||||
|
||||
if any(user.id is None for user in users):
|
||||
raise ValueError("All users must be persisted in the database.")
|
||||
|
||||
if product_count <= 0:
|
||||
raise ValueError("Product count must be positive.")
|
||||
|
||||
joint_transaction = Transaction.joint(
|
||||
user_id=instigator.id,
|
||||
product_id=product.id,
|
||||
product_count=product_count,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
sql_session.add(joint_transaction)
|
||||
sql_session.flush() # Ensure joint_transaction gets an ID
|
||||
|
||||
for user in users:
|
||||
buy_transaction = Transaction.joint_buy_product(
|
||||
user_id=user.id,
|
||||
joint_transaction_id=joint_transaction.id,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
sql_session.add(buy_transaction)
|
||||
|
||||
sql_session.commit()
|
||||
@@ -0,0 +1,303 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CTE,
|
||||
BindParameter,
|
||||
and_,
|
||||
bindparam,
|
||||
case,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from dibbler.queries.product_stock import _product_stock_query
|
||||
|
||||
|
||||
def _product_owners_query(
|
||||
product_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until: BindParameter[datetime] | datetime | None = None,
|
||||
cte_name: str = "rec_cte",
|
||||
) -> CTE:
|
||||
"""
|
||||
The inner query for inferring the owners of a given product.
|
||||
"""
|
||||
|
||||
if use_cache:
|
||||
print("WARNING: Using cache for users owning product query is not implemented yet.")
|
||||
|
||||
if isinstance(product_id, int):
|
||||
product_id = bindparam("product_id", value=product_id)
|
||||
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until)
|
||||
|
||||
product_stock = _product_stock_query(
|
||||
product_id=product_id,
|
||||
use_cache=use_cache,
|
||||
until=until,
|
||||
)
|
||||
|
||||
# Subset of transactions that we'll want to iterate over.
|
||||
trx_subset = (
|
||||
select(
|
||||
func.row_number().over(order_by=Transaction.time.desc()).label("i"),
|
||||
Transaction.time,
|
||||
Transaction.id,
|
||||
Transaction.type_,
|
||||
Transaction.user_id,
|
||||
Transaction.product_count,
|
||||
)
|
||||
# TODO: maybe add value constraint on ADJUST_STOCK?
|
||||
.where(
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
# TransactionType.BUY_PRODUCT,
|
||||
TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
# TransactionType.JOINT,
|
||||
# TransactionType.THROW_PRODUCT,
|
||||
]
|
||||
),
|
||||
Transaction.product_id == product_id,
|
||||
CONST_TRUE if until is None else Transaction.time <= until,
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
.subquery()
|
||||
)
|
||||
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_NONE.label("user_id"),
|
||||
CONST_ZERO.label("product_count"),
|
||||
product_stock.scalar_subquery().label("products_left_to_account_for"),
|
||||
)
|
||||
|
||||
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
|
||||
|
||||
recursive_elements = (
|
||||
select(
|
||||
trx_subset.c.i,
|
||||
trx_subset.c.time,
|
||||
trx_subset.c.id.label("transaction_id"),
|
||||
# Who added the product (if any)
|
||||
case(
|
||||
# Someone adds the product -> they own it
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
trx_subset.c.user_id,
|
||||
),
|
||||
else_=CONST_NONE,
|
||||
).label("user_id"),
|
||||
# How many products did they add (if any)
|
||||
case(
|
||||
# Someone adds the product -> they added a certain amount of products
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
trx_subset.c.product_count,
|
||||
),
|
||||
# Stock got adjusted upwards -> consider those products as added by nobody
|
||||
(
|
||||
(trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column())
|
||||
and (trx_subset.c.product_count > CONST_ZERO),
|
||||
trx_subset.c.product_count,
|
||||
),
|
||||
else_=CONST_ZERO,
|
||||
).label("product_count"),
|
||||
# How many products left to account for
|
||||
case(
|
||||
# Someone adds the product -> increase the number of products left to account for
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
|
||||
),
|
||||
# Someone buys/joins/throws the product -> decrease the number of products left to account for
|
||||
# (
|
||||
# trx_subset.c.type_.in_(
|
||||
# [
|
||||
# TransactionType.BUY_PRODUCT,
|
||||
# TransactionType.JOINT,
|
||||
# TransactionType.THROW_PRODUCT,
|
||||
# ]
|
||||
# ),
|
||||
# recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
|
||||
# ),
|
||||
# Someone adjusts the stock ->
|
||||
# If adjusted upwards -> products owned by nobody, decrease products left to account for
|
||||
# If adjusted downwards -> products taken away from owners, decrease products left to account for
|
||||
(
|
||||
(trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column())
|
||||
and (trx_subset.c.product_count > CONST_ZERO),
|
||||
recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
|
||||
),
|
||||
# (
|
||||
# (trx_subset.c.type_ == TransactionType.ADJUST_STOCK)
|
||||
# and (trx_subset.c.product_count < 0),
|
||||
# recursive_cte.c.products_left_to_account_for + trx_subset.c.product_count,
|
||||
# ),
|
||||
else_=recursive_cte.c.products_left_to_account_for,
|
||||
).label("products_left_to_account_for"),
|
||||
)
|
||||
.select_from(trx_subset)
|
||||
.where(
|
||||
and_(
|
||||
trx_subset.c.i == recursive_cte.c.i + CONST_ONE,
|
||||
recursive_cte.c.products_left_to_account_for > CONST_ZERO,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return recursive_cte.union_all(recursive_elements)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductOwnersLogEntry:
|
||||
transaction: Transaction
|
||||
user: User | None
|
||||
products_left_to_account_for: int
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def product_owners_log(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> list[ProductOwnersLogEntry]:
|
||||
"""
|
||||
Returns a log of the product ownership calculation for the given product.
|
||||
|
||||
If 'until' is given, only transactions up to that time are considered.
|
||||
"""
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_owners_query(
|
||||
product_id=product.id,
|
||||
use_cache=use_cache,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.execute(
|
||||
select(
|
||||
Transaction,
|
||||
User,
|
||||
recursive_cte.c.products_left_to_account_for,
|
||||
)
|
||||
.select_from(recursive_cte)
|
||||
.join(
|
||||
Transaction,
|
||||
onclause=Transaction.id == recursive_cte.c.transaction_id,
|
||||
)
|
||||
.join(
|
||||
User,
|
||||
onclause=User.id == recursive_cte.c.user_id,
|
||||
isouter=True,
|
||||
)
|
||||
.order_by(recursive_cte.c.time.desc())
|
||||
).all()
|
||||
|
||||
if result is None:
|
||||
# If there are no transactions for this product, the query should return an empty list, not None.
|
||||
raise RuntimeError(
|
||||
f"Something went wrong while calculating the owner log for product {product.name} (ID: {product.id})."
|
||||
)
|
||||
|
||||
return [
|
||||
ProductOwnersLogEntry(
|
||||
transaction=row[0],
|
||||
user=row[1],
|
||||
products_left_to_account_for=row[2],
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
|
||||
# TODO: add until transaction parameter
|
||||
|
||||
|
||||
def product_owners(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until: datetime | None = None,
|
||||
) -> list[User | None]:
|
||||
"""
|
||||
Returns an ordered list of users owning the given product.
|
||||
|
||||
If 'until' is given, only transactions up to that time are considered.
|
||||
"""
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_owners_query(
|
||||
product_id=product.id,
|
||||
use_cache=use_cache,
|
||||
until=until,
|
||||
)
|
||||
|
||||
db_result = sql_session.execute(
|
||||
select(
|
||||
recursive_cte.c.products_left_to_account_for,
|
||||
recursive_cte.c.product_count,
|
||||
User,
|
||||
)
|
||||
.join(User, User.id == recursive_cte.c.user_id, isouter=True)
|
||||
.order_by(recursive_cte.c.time.desc())
|
||||
).all()
|
||||
|
||||
print(db_result)
|
||||
|
||||
result: list[User | None] = []
|
||||
none_count = 0
|
||||
|
||||
# We are moving backwards through history, but this is the order we want to return the list
|
||||
# There are 3 cases:
|
||||
# User is not none -> add user product_count times
|
||||
# User is none, and product_count is not 0 -> add None product_count times
|
||||
# User is none, and product_count is 0 -> check how much products are left to account for,
|
||||
|
||||
# TODO: embed this into the query itself?
|
||||
for products_left_to_account_for, product_count, user in db_result:
|
||||
if user is not None:
|
||||
if products_left_to_account_for < 0:
|
||||
result.extend([user] * (product_count + products_left_to_account_for))
|
||||
else:
|
||||
result.extend([user] * product_count)
|
||||
elif product_count != 0:
|
||||
if products_left_to_account_for < 0:
|
||||
none_count += product_count + products_left_to_account_for
|
||||
else:
|
||||
none_count += product_count
|
||||
else:
|
||||
pass
|
||||
|
||||
# none_count += user_count
|
||||
# else:
|
||||
|
||||
result.extend([None] * none_count)
|
||||
|
||||
# # NOTE: if the last line exeeds the product count, we need to truncate it
|
||||
# result.extend([user] * min(user_count, products_left_to_account_for))
|
||||
|
||||
# redistribute the user counts to a list of users
|
||||
|
||||
return list(result)
|
||||
@@ -0,0 +1,286 @@
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BindParameter,
|
||||
ColumnElement,
|
||||
Integer,
|
||||
asc,
|
||||
case,
|
||||
cast,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
|
||||
|
||||
def _product_price_query(
|
||||
product_id: int | ColumnElement[int],
|
||||
use_cache: bool = True,
|
||||
until: BindParameter[datetime] | datetime | None = None,
|
||||
until_including: BindParameter[bool] | bool = True,
|
||||
cte_name: str = "rec_cte",
|
||||
):
|
||||
"""
|
||||
The inner query for calculating the product price.
|
||||
"""
|
||||
|
||||
if use_cache:
|
||||
print("WARNING: Using cache for product price query is not implemented yet.")
|
||||
|
||||
if isinstance(product_id, int):
|
||||
product_id = BindParameter("product_id", value=product_id)
|
||||
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until)
|
||||
|
||||
if isinstance(until_including, bool):
|
||||
until_including = BindParameter("until_including", value=until_including)
|
||||
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_ZERO.label("price"),
|
||||
CONST_ZERO.label("product_count"),
|
||||
)
|
||||
|
||||
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
|
||||
|
||||
# Subset of transactions that we'll want to iterate over.
|
||||
trx_subset = (
|
||||
select(
|
||||
func.row_number().over(order_by=asc(Transaction.time)).label("i"),
|
||||
Transaction.id,
|
||||
Transaction.time,
|
||||
Transaction.type_,
|
||||
Transaction.product_count,
|
||||
Transaction.per_product,
|
||||
)
|
||||
.where(
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
TransactionType.JOINT.as_literal_column(),
|
||||
]
|
||||
),
|
||||
Transaction.product_id == product_id,
|
||||
case(
|
||||
(until_including, Transaction.time <= until),
|
||||
else_=Transaction.time < until,
|
||||
)
|
||||
if until is not None
|
||||
else CONST_TRUE,
|
||||
)
|
||||
.order_by(Transaction.time.asc())
|
||||
.alias("trx_subset")
|
||||
)
|
||||
|
||||
recursive_elements = (
|
||||
select(
|
||||
trx_subset.c.i,
|
||||
trx_subset.c.time,
|
||||
trx_subset.c.id.label("transaction_id"),
|
||||
case(
|
||||
# Someone buys the product -> price remains the same.
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.price,
|
||||
),
|
||||
# Someone adds the product -> price is recalculated based on
|
||||
# product count, previous price, and new price.
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
cast(
|
||||
func.ceil(
|
||||
(
|
||||
recursive_cte.c.price
|
||||
* func.max(recursive_cte.c.product_count, CONST_ZERO)
|
||||
+ trx_subset.c.per_product * trx_subset.c.product_count
|
||||
)
|
||||
/ (
|
||||
# The running product count can be negative if the accounting is bad.
|
||||
# This ensures that we never end up with negative prices or zero divisions
|
||||
# and other disastrous phenomena.
|
||||
func.max(recursive_cte.c.product_count, CONST_ZERO)
|
||||
+ trx_subset.c.product_count
|
||||
)
|
||||
),
|
||||
Integer,
|
||||
),
|
||||
),
|
||||
# Someone adjusts the stock -> price remains the same.
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
recursive_cte.c.price,
|
||||
),
|
||||
# Should never happen
|
||||
else_=recursive_cte.c.price,
|
||||
).label("price"),
|
||||
case(
|
||||
# Someone buys the product -> product count is reduced.
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.product_count - trx_subset.c.product_count,
|
||||
),
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.JOINT.as_literal_column(),
|
||||
recursive_cte.c.product_count - trx_subset.c.product_count,
|
||||
),
|
||||
# Someone adds the product -> product count is increased.
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.product_count + trx_subset.c.product_count,
|
||||
),
|
||||
# Someone adjusts the stock -> product count is adjusted.
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
recursive_cte.c.product_count + trx_subset.c.product_count,
|
||||
),
|
||||
# Should never happen
|
||||
else_=recursive_cte.c.product_count,
|
||||
).label("product_count"),
|
||||
)
|
||||
.select_from(trx_subset)
|
||||
.where(trx_subset.c.i == recursive_cte.c.i + CONST_ONE)
|
||||
)
|
||||
|
||||
return recursive_cte.union_all(recursive_elements)
|
||||
|
||||
|
||||
# TODO: create a function for the log that pretty prints the log entries
|
||||
# for debugging purposes
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductPriceLogEntry:
|
||||
transaction: Transaction
|
||||
price: int
|
||||
product_count: int
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def product_price_log(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> list[ProductPriceLogEntry]:
|
||||
"""
|
||||
Calculates the price of a product and returns a log of the price changes.
|
||||
"""
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_price_query(
|
||||
product.id,
|
||||
use_cache=use_cache,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.execute(
|
||||
select(
|
||||
Transaction,
|
||||
recursive_cte.c.price,
|
||||
recursive_cte.c.product_count,
|
||||
)
|
||||
.select_from(recursive_cte)
|
||||
.join(
|
||||
Transaction,
|
||||
onclause=Transaction.id == recursive_cte.c.transaction_id,
|
||||
)
|
||||
.order_by(recursive_cte.c.i.asc())
|
||||
).all()
|
||||
|
||||
if result is None:
|
||||
# If there are no transactions for this product, the query should return an empty list, not None.
|
||||
raise RuntimeError(
|
||||
f"Something went wrong while calculating the price log for product {product.name} (ID: {product.id})."
|
||||
)
|
||||
|
||||
return [
|
||||
ProductPriceLogEntry(
|
||||
transaction=row[0],
|
||||
price=row.price,
|
||||
product_count=row.product_count,
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def product_price(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until: Transaction | None = None,
|
||||
include_interest: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
Calculates the price of a product.
|
||||
"""
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_price_query(
|
||||
product.id,
|
||||
use_cache=use_cache,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
# TODO: optionally verify subresults:
|
||||
# - product_count should never be negative (but this happens sometimes, so just a warning)
|
||||
# - price should never be negative
|
||||
|
||||
result = sql_session.scalars(
|
||||
select(recursive_cte.c.price)
|
||||
.order_by(recursive_cte.c.i.desc())
|
||||
.limit(CONST_ONE)
|
||||
.offset(CONST_ZERO)
|
||||
).one_or_none()
|
||||
|
||||
if result is None:
|
||||
# If there are no transactions for this product, the query should return 0, not None.
|
||||
raise RuntimeError(
|
||||
f"Something went wrong while calculating the price for product {product.name} (ID: {product.id})."
|
||||
)
|
||||
|
||||
if include_interest:
|
||||
interest_rate = (
|
||||
sql_session.scalar(
|
||||
select(Transaction.interest_rate_percent)
|
||||
.where(
|
||||
Transaction.type_ == TransactionType.ADJUST_INTEREST,
|
||||
CONST_TRUE if until is None else Transaction.time <= until.time,
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(CONST_ONE)
|
||||
)
|
||||
or DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
)
|
||||
result = math.ceil(result * interest_rate / 100)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
BindParameter,
|
||||
Select,
|
||||
case,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_TRUE
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
|
||||
|
||||
def _product_stock_query(
|
||||
product_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until: BindParameter[datetime] | datetime | None = None,
|
||||
) -> Select:
|
||||
"""
|
||||
The inner query for calculating the product stock.
|
||||
"""
|
||||
|
||||
if use_cache:
|
||||
print("WARNING: Using cache for product stock query is not implemented yet.")
|
||||
|
||||
if isinstance(product_id, int):
|
||||
product_id = BindParameter("product_id", value=product_id)
|
||||
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until)
|
||||
|
||||
query = select(
|
||||
func.sum(
|
||||
case(
|
||||
(
|
||||
Transaction.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
Transaction.product_count,
|
||||
),
|
||||
(
|
||||
Transaction.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
Transaction.product_count,
|
||||
),
|
||||
(
|
||||
Transaction.type_ == TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
-Transaction.product_count,
|
||||
),
|
||||
(
|
||||
Transaction.type_ == TransactionType.JOINT.as_literal_column(),
|
||||
-Transaction.product_count,
|
||||
),
|
||||
(
|
||||
Transaction.type_ == TransactionType.THROW_PRODUCT.as_literal_column(),
|
||||
-Transaction.product_count,
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
)
|
||||
).where(
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.JOINT.as_literal_column(),
|
||||
TransactionType.THROW_PRODUCT.as_literal_column(),
|
||||
]
|
||||
),
|
||||
Transaction.product_id == product_id,
|
||||
Transaction.time <= until if until is not None else CONST_TRUE,
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
# TODO: add until transaction parameter
|
||||
|
||||
|
||||
def product_stock(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until: datetime | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Returns the number of products in stock.
|
||||
|
||||
If 'until' is given, only transactions up to that time are considered.
|
||||
"""
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
query = _product_stock_query(
|
||||
product_id=product.id,
|
||||
use_cache=use_cache,
|
||||
until=until,
|
||||
)
|
||||
|
||||
result = sql_session.scalars(query).one_or_none()
|
||||
|
||||
return result or 0
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy import and_, literal, not_, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product
|
||||
|
||||
|
||||
def search_product(
|
||||
string: str,
|
||||
sql_session: Session,
|
||||
find_hidden_products=False,
|
||||
) -> Product | list[Product]:
|
||||
exact_match = sql_session.scalars(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.bar_code == string,
|
||||
and_(
|
||||
Product.name == string,
|
||||
literal(True) if find_hidden_products else not_(Product.hidden),
|
||||
),
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if exact_match:
|
||||
return exact_match
|
||||
|
||||
product_list = sql_session.scalars(
|
||||
select(Product).where(
|
||||
or_(
|
||||
Product.bar_code.ilike(f"%{string}%"),
|
||||
and_(
|
||||
Product.name.ilike(f"%{string}%"),
|
||||
literal(True) if find_hidden_products else not_(Product.hidden),
|
||||
),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
return list(product_list)
|
||||
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import User
|
||||
|
||||
|
||||
def search_user(
|
||||
string: str,
|
||||
sql_session: Session,
|
||||
) -> User | list[User]:
|
||||
string = string.lower()
|
||||
|
||||
exact_match = sql_session.scalars(
|
||||
select(User).where(
|
||||
or_(
|
||||
User.name == string,
|
||||
User.card == string,
|
||||
User.rfid == string,
|
||||
)
|
||||
)
|
||||
).first()
|
||||
|
||||
if exact_match:
|
||||
return exact_match
|
||||
|
||||
user_list = sql_session.scalars(
|
||||
select(User).where(
|
||||
or_(
|
||||
User.name.ilike(f"%{string}%"),
|
||||
User.card.ilike(f"%{string}%"),
|
||||
User.rfid.ilike(f"%{string}%"),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
return list(user_list)
|
||||
@@ -0,0 +1,90 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
|
||||
|
||||
# TODO: should this include full joint transactions that involve a user?
|
||||
# TODO: should this involve throw-away transactions that affects a user?
|
||||
def transaction_log(
|
||||
sql_session: Session,
|
||||
user: User | None = None,
|
||||
product: Product | None = None,
|
||||
exclusive_after: bool = False,
|
||||
after_time=None,
|
||||
after_transaction_id: int | None = None,
|
||||
exclusive_before: bool = False,
|
||||
before_time=None,
|
||||
before_transaction_id: int | None = None,
|
||||
transaction_type: list[TransactionType] | None = None,
|
||||
negate_transaction_type_filter: bool = False,
|
||||
limit: int | None = None,
|
||||
) -> list[Transaction]:
|
||||
"""
|
||||
Retrieve the transaction log, optionally filtered.
|
||||
|
||||
Only one of `user` or `product` may be specified.
|
||||
Only one of `after_time` or `after_transaction_id` may be specified.
|
||||
Only one of `before_time` or `before_transaction_id` may be specified.
|
||||
|
||||
The before and after filters are inclusive by default.
|
||||
"""
|
||||
|
||||
if not (user is None or product is None):
|
||||
raise ValueError("Cannot filter by both user and product.")
|
||||
|
||||
if user is not None and user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if product is not None and product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if not (after_time is None or after_transaction_id is None):
|
||||
raise ValueError("Cannot filter by both from_time and from_transaction_id.")
|
||||
|
||||
query = select(Transaction)
|
||||
if user is not None:
|
||||
query = query.where(Transaction.user_id == user.id)
|
||||
if product is not None:
|
||||
query = query.where(Transaction.product_id == product.id)
|
||||
|
||||
if after_time is not None:
|
||||
if exclusive_after:
|
||||
query = query.where(Transaction.time > after_time)
|
||||
else:
|
||||
query = query.where(Transaction.time >= after_time)
|
||||
if after_transaction_id is not None:
|
||||
if exclusive_after:
|
||||
query = query.where(Transaction.id > after_transaction_id)
|
||||
else:
|
||||
query = query.where(Transaction.id >= after_transaction_id)
|
||||
|
||||
if before_time is not None:
|
||||
if exclusive_before:
|
||||
query = query.where(Transaction.time < before_time)
|
||||
else:
|
||||
query = query.where(Transaction.time <= before_time)
|
||||
if before_transaction_id is not None:
|
||||
if exclusive_before:
|
||||
query = query.where(Transaction.id < before_transaction_id)
|
||||
else:
|
||||
query = query.where(Transaction.id <= before_transaction_id)
|
||||
|
||||
if transaction_type is not None:
|
||||
if negate_transaction_type_filter:
|
||||
query = query.where(~Transaction.type_.in_(transaction_type))
|
||||
else:
|
||||
query = query.where(Transaction.type_.in_(transaction_type))
|
||||
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
query = query.order_by(Transaction.time.asc(), Transaction.id.asc())
|
||||
result = sql_session.scalars(query).all()
|
||||
|
||||
return list(result)
|
||||
@@ -0,0 +1,361 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
CTE,
|
||||
BindParameter,
|
||||
Float,
|
||||
Integer,
|
||||
and_,
|
||||
case,
|
||||
cast,
|
||||
column,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO, const
|
||||
from dibbler.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_INTEREST_RATE_PERCENTAGE,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
)
|
||||
from dibbler.queries.product_price import _product_price_query
|
||||
|
||||
|
||||
def _user_balance_query(
|
||||
user_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until: BindParameter[datetime] | BindParameter[None] | datetime | None = None,
|
||||
until_including: BindParameter[bool] | bool = True,
|
||||
cte_name: str = "rec_cte",
|
||||
) -> CTE:
|
||||
"""
|
||||
The inner query for calculating the user's balance.
|
||||
"""
|
||||
|
||||
if use_cache:
|
||||
print("WARNING: Using cache for user balance query is not implemented yet.")
|
||||
|
||||
if isinstance(user_id, int):
|
||||
user_id = BindParameter("user_id", value=user_id)
|
||||
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until, type_=datetime)
|
||||
|
||||
if isinstance(until_including, bool):
|
||||
until_including = BindParameter("until_including", value=until_including, type_=bool)
|
||||
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_ZERO.label("balance"),
|
||||
const(DEFAULT_INTEREST_RATE_PERCENTAGE).label("interest_rate_percent"),
|
||||
const(DEFAULT_PENALTY_THRESHOLD).label("penalty_threshold"),
|
||||
const(DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE).label("penalty_multiplier_percent"),
|
||||
)
|
||||
|
||||
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
|
||||
|
||||
# Subset of transactions that we'll want to iterate over.
|
||||
trx_subset = (
|
||||
select(
|
||||
func.row_number().over(order_by=Transaction.time.asc()).label("i"),
|
||||
Transaction.amount,
|
||||
Transaction.id,
|
||||
Transaction.interest_rate_percent,
|
||||
Transaction.penalty_multiplier_percent,
|
||||
Transaction.penalty_threshold,
|
||||
Transaction.product_count,
|
||||
Transaction.product_id,
|
||||
Transaction.time,
|
||||
Transaction.transfer_user_id,
|
||||
Transaction.type_,
|
||||
)
|
||||
.where(
|
||||
or_(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_BALANCE.as_literal_column(),
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.TRANSFER.as_literal_column(),
|
||||
# TODO: join this with the JOINT transactions, and determine
|
||||
# how much the current user paid for the product.
|
||||
TransactionType.JOINT_BUY_PRODUCT.as_literal_column(),
|
||||
]
|
||||
),
|
||||
),
|
||||
and_(
|
||||
Transaction.type_ == TransactionType.TRANSFER.as_literal_column(),
|
||||
Transaction.transfer_user_id == user_id,
|
||||
),
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.THROW_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_INTEREST.as_literal_column(),
|
||||
TransactionType.ADJUST_PENALTY.as_literal_column(),
|
||||
]
|
||||
),
|
||||
),
|
||||
case(
|
||||
(until_including, Transaction.time <= until),
|
||||
else_=Transaction.time < until,
|
||||
)
|
||||
if until is not None
|
||||
else CONST_TRUE,
|
||||
)
|
||||
.order_by(Transaction.time.asc())
|
||||
.alias("trx_subset")
|
||||
)
|
||||
|
||||
recursive_elements = (
|
||||
select(
|
||||
trx_subset.c.i,
|
||||
trx_subset.c.time,
|
||||
trx_subset.c.id.label("transaction_id"),
|
||||
case(
|
||||
# Adjusts balance -> balance gets adjusted
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_BALANCE.as_literal_column(),
|
||||
recursive_cte.c.balance + trx_subset.c.amount,
|
||||
),
|
||||
# Adds a product -> balance increases
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.balance + trx_subset.c.amount,
|
||||
),
|
||||
# Buys a product -> balance decreases
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.balance
|
||||
- (
|
||||
trx_subset.c.product_count
|
||||
# Price of a single product, accounted for penalties and interest.
|
||||
* cast(
|
||||
func.ceil(
|
||||
# TODO: This can get quite expensive real quick, so we should do some caching of the
|
||||
# product prices somehow.
|
||||
# Base price
|
||||
(
|
||||
# FIXME: this always returns 0 for some reason...
|
||||
select(cast(column("price"), Float))
|
||||
.select_from(
|
||||
_product_price_query(
|
||||
trx_subset.c.product_id,
|
||||
use_cache=use_cache,
|
||||
until=trx_subset.c.time,
|
||||
until_including=False,
|
||||
cte_name="product_price_cte",
|
||||
)
|
||||
)
|
||||
.order_by(column("i").desc())
|
||||
.limit(CONST_ONE)
|
||||
).scalar_subquery()
|
||||
# TODO: should interest be applied before or after the penalty multiplier?
|
||||
# at the moment of writing, after sound right, but maybe ask someone?
|
||||
# Interest
|
||||
* (cast(recursive_cte.c.interest_rate_percent, Float) / const(100))
|
||||
# TODO: these should be added together, not multiplied, see specification
|
||||
# Penalty
|
||||
* case(
|
||||
(
|
||||
recursive_cte.c.balance < recursive_cte.c.penalty_threshold,
|
||||
(
|
||||
cast(recursive_cte.c.penalty_multiplier_percent, Float)
|
||||
/ const(100)
|
||||
),
|
||||
),
|
||||
else_=const(1.0),
|
||||
)
|
||||
),
|
||||
Integer,
|
||||
)
|
||||
),
|
||||
),
|
||||
# Transfers money to self -> balance increases
|
||||
(
|
||||
and_(
|
||||
trx_subset.c.type_ == TransactionType.TRANSFER.as_literal_column(),
|
||||
trx_subset.c.transfer_user_id == user_id,
|
||||
),
|
||||
recursive_cte.c.balance + trx_subset.c.amount,
|
||||
),
|
||||
# Transfers money from self -> balance decreases
|
||||
(
|
||||
and_(
|
||||
trx_subset.c.type_ == TransactionType.TRANSFER.as_literal_column(),
|
||||
trx_subset.c.transfer_user_id != user_id,
|
||||
),
|
||||
recursive_cte.c.balance - trx_subset.c.amount,
|
||||
),
|
||||
# Throws a product -> if the user is considered to have bought it, balance increases
|
||||
# TODO:
|
||||
# (
|
||||
# trx_subset.c.type_ == TransactionType.THROW_PRODUCT,
|
||||
# recursive_cte.c.balance + trx_subset.c.amount,
|
||||
# ),
|
||||
# Interest adjustment -> balance stays the same
|
||||
# Penalty adjustment -> balance stays the same
|
||||
else_=recursive_cte.c.balance,
|
||||
).label("balance"),
|
||||
case(
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_INTEREST.as_literal_column(),
|
||||
trx_subset.c.interest_rate_percent,
|
||||
),
|
||||
else_=recursive_cte.c.interest_rate_percent,
|
||||
).label("interest_rate_percent"),
|
||||
case(
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_PENALTY.as_literal_column(),
|
||||
trx_subset.c.penalty_threshold,
|
||||
),
|
||||
else_=recursive_cte.c.penalty_threshold,
|
||||
).label("penalty_threshold"),
|
||||
case(
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_PENALTY.as_literal_column(),
|
||||
trx_subset.c.penalty_multiplier_percent,
|
||||
),
|
||||
else_=recursive_cte.c.penalty_multiplier_percent,
|
||||
).label("penalty_multiplier_percent"),
|
||||
)
|
||||
.select_from(trx_subset)
|
||||
.where(trx_subset.c.i == recursive_cte.c.i + CONST_ONE)
|
||||
)
|
||||
|
||||
return recursive_cte.union_all(recursive_elements)
|
||||
|
||||
|
||||
# TODO: create a function for the log that pretty prints the log entries
|
||||
# for debugging purposes
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserBalanceLogEntry:
|
||||
transaction: Transaction
|
||||
balance: int
|
||||
interest_rate_percent: int
|
||||
penalty_threshold: int
|
||||
penalty_multiplier_percent: int
|
||||
|
||||
def is_penalized(self) -> bool:
|
||||
"""
|
||||
Returns whether this exact transaction is penalized.
|
||||
"""
|
||||
|
||||
return False
|
||||
|
||||
# return self.transaction.type_ == TransactionType.BUY_PRODUCT and prev?
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def user_balance_log(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
use_cache: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> list[UserBalanceLogEntry]:
|
||||
"""
|
||||
Returns a log of the user's balance over time, including interest and penalty adjustments.
|
||||
|
||||
If 'until' is given, only transactions up to that time are considered.
|
||||
"""
|
||||
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _user_balance_query(
|
||||
user.id,
|
||||
use_cache=use_cache,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.execute(
|
||||
select(
|
||||
Transaction,
|
||||
recursive_cte.c.balance,
|
||||
recursive_cte.c.interest_rate_percent,
|
||||
recursive_cte.c.penalty_threshold,
|
||||
recursive_cte.c.penalty_multiplier_percent,
|
||||
)
|
||||
.select_from(recursive_cte)
|
||||
.join(
|
||||
Transaction,
|
||||
onclause=Transaction.id == recursive_cte.c.transaction_id,
|
||||
)
|
||||
.order_by(recursive_cte.c.i.asc())
|
||||
).all()
|
||||
|
||||
if result is None:
|
||||
# If there are no transactions for this user, the query should return 0, not None.
|
||||
raise RuntimeError(
|
||||
f"Something went wrong while calculating the balance for user {user.name} (ID: {user.id})."
|
||||
)
|
||||
|
||||
return [
|
||||
UserBalanceLogEntry(
|
||||
transaction=row[0],
|
||||
balance=row.balance,
|
||||
interest_rate_percent=row.interest_rate_percent,
|
||||
penalty_threshold=row.penalty_threshold,
|
||||
penalty_multiplier_percent=row.penalty_multiplier_percent,
|
||||
)
|
||||
for row in result
|
||||
]
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
|
||||
def user_balance(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
use_cache: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Calculates the balance of a user.
|
||||
|
||||
If 'until' is given, only transactions up to that time are considered.
|
||||
"""
|
||||
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
recursive_cte = _user_balance_query(
|
||||
user.id,
|
||||
use_cache=use_cache,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.scalar(
|
||||
select(recursive_cte.c.balance)
|
||||
.order_by(recursive_cte.c.i.desc())
|
||||
.limit(CONST_ONE)
|
||||
.offset(CONST_ZERO)
|
||||
)
|
||||
|
||||
if result is None:
|
||||
# If there are no transactions for this user, the query should return 0, not None.
|
||||
raise RuntimeError(
|
||||
f"Something went wrong while calculating the balance for user {user.name} (ID: {user.id})."
|
||||
)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,10 @@
|
||||
# This absoulutely needs a cache, else we can't stop recursing until we know all owners for all products...
|
||||
#
|
||||
# Since we know that the non-owned products will not get renowned by the user by other means,
|
||||
# we can just check for ownership on the products that have an ADD_PRODUCT transaction for the user.
|
||||
# between now and the cached time.
|
||||
#
|
||||
# However, the opposite way is more difficult. The cache will store which products are owned by which users,
|
||||
# but we still need to check if the user passes out of ownership for the item, without needing to check past
|
||||
# the cache time. Maybe we also need to store the queue number(s) per user/product combo in the cache? What if
|
||||
# a user has products multiple places in the queue, interleaved with other users?
|
||||
Reference in New Issue
Block a user