fixup! WIP

This commit is contained in:
2025-06-11 20:01:11 +02:00
parent 5c0b2b5229
commit 885e989659
35 changed files with 1467 additions and 1010 deletions

View File

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

View File

@@ -1,10 +0,0 @@
from datetime import datetime
from sqlalchemy import Integer, DateTime
from sqlalchemy.orm import Mapped, mapped_column
from dibbler.models import Base
class InterestRate(Base):
timestamp: Mapped[datetime] = mapped_column(DateTime)
percentage: Mapped[int] = mapped_column(Integer)

View File

@@ -6,35 +6,41 @@ from sqlalchemy import (
Boolean,
Integer,
String,
case,
func,
select,
)
from sqlalchemy.orm import (
Mapped,
Session,
mapped_column,
)
import dibbler.models.User as user
from .Base import Base
from .Transaction import Transaction
from .TransactionType import TransactionType
# if TYPE_CHECKING:
# from .PurchaseEntry import PurchaseEntry
# from .UserProducts import UserProducts
class Product(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
bar_code: Mapped[str] = mapped_column(String(13), unique=True)
"""
The bar code of the product.
This is a unique identifier for the product, typically a 13-digit
EAN-13 code.
"""
name: Mapped[str] = mapped_column(String(45))
# price: Mapped[int] = mapped_column(Integer)
# stock: Mapped[int] = mapped_column(Integer)
"""
The name of the product.
Please don't write fanfics here, this is not a place for that.
"""
hidden: Mapped[bool] = mapped_column(Boolean, default=False)
"""
Whether the product is hidden from the user interface.
Hidden products are not shown in the product list, but can still be
used in transactions.
"""
def __init__(
self: Self,
@@ -45,85 +51,3 @@ class Product(Base):
self.bar_code = bar_code
self.name = name
self.hidden = hidden
# - count (virtual)
def stock(self: Self, sql_session: Session) -> int:
"""
Returns the number of products in stock.
"""
result = sql_session.scalars(
select(
func.sum(
case(
(
Transaction.type_ == TransactionType.ADD_PRODUCT,
Transaction.product_count,
),
(
Transaction.type_ == TransactionType.BUY_PRODUCT,
-Transaction.product_count,
),
(
Transaction.type_ == TransactionType.ADJUST_STOCK,
Transaction.product_count,
),
else_=0,
)
)
).where(
Transaction.type_.in_(
[
TransactionType.BUY_PRODUCT,
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
]
),
Transaction.product_id == self.id,
)
).one_or_none()
return result or 0
def remaining_with_exact_price(self: Self, sql_session: Session) -> list[int]:
"""
Retrieves the remaining products with their exact price as they were bought.
"""
stock = self.stock(sql_session)
# TODO: only retrieve as many transactions as exists in the stock
last_added = sql_session.scalars(
select(
func.row_number(),
Transaction.time,
Transaction.per_product,
Transaction.product_count,
)
.where(
Transaction.type_ == TransactionType.ADD_PRODUCT,
Transaction.product_id == self.id,
)
.order_by(Transaction.time.desc())
).all()
# result = []
# while stock > 0 and last_added:
...
def price(self: Self, sql_session: Session) -> int:
"""
Returns the price of the product.
Average price over the last bought products.
"""
return Transaction.product_price(sql_session=sql_session, product=self)
def owned_by_user(self: Self, sql_session: Session) -> dict[user.User, int]:
"""
Returns an overview of how many of the remaining products are owned by which user.
"""
...

View File

@@ -5,7 +5,11 @@ from sqlalchemy.orm import Mapped, mapped_column
from dibbler.models import Base
class ProductPriceCache(Base):
class ProductCache(Base):
product_id: Mapped[int] = mapped_column(Integer, primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime)
price: Mapped[int] = mapped_column(Integer)
price_timestamp: Mapped[datetime] = mapped_column(DateTime)
stock: Mapped[int] = mapped_column(Integer)
stock_timestamp: Mapped[datetime] = mapped_column(DateTime)

View File

@@ -9,19 +9,12 @@ from sqlalchemy import (
ForeignKey,
Integer,
Text,
asc,
case,
cast,
func,
literal,
select,
)
from sqlalchemy import (
Enum as SQLEnum,
)
from sqlalchemy.orm import (
Mapped,
Session,
mapped_column,
relationship,
)
@@ -40,21 +33,32 @@ if TYPE_CHECKING:
# maybe we should add some sort of joint transaction id field to allow multiple transactions to be grouped together?
_DYNAMIC_FIELDS: set[str] = {
"amount",
"interest_rate_percent",
"penalty_multiplier_percent",
"penalty_threshold",
"per_product",
"user_id",
"transfer_user_id",
"product_id",
"product_count",
"product_id",
"transfer_user_id",
}
_EXPECTED_FIELDS: dict[TransactionType, set[str]] = {
TransactionType.ADJUST_BALANCE: {"user_id"},
TransactionType.ADJUST_STOCK: {"user_id", "product_id", "product_count"},
TransactionType.TRANSFER: {"user_id", "transfer_user_id"},
TransactionType.ADD_PRODUCT: {"user_id", "product_id", "per_product", "product_count"},
TransactionType.BUY_PRODUCT: {"user_id", "product_id", "product_count"},
TransactionType.ADD_PRODUCT: {"amount", "per_product", "product_count", "product_id"},
TransactionType.ADJUST_BALANCE: {"amount"},
TransactionType.ADJUST_INTEREST: {"interest_rate_percent"},
TransactionType.ADJUST_PENALTY: {"penalty_multiplier_percent", "penalty_threshold"},
TransactionType.ADJUST_STOCK: {"product_count", "product_id"},
# TODO: remove amount from BUY_PRODUCT
# this requires modifications to user credit calculations
TransactionType.BUY_PRODUCT: {"amount", "product_count", "product_id"},
TransactionType.TRANSFER: {"amount", "transfer_user_id"},
}
assert all(x <= _DYNAMIC_FIELDS for x in _EXPECTED_FIELDS.values()), (
"All expected fields must be part of _DYNAMIC_FIELDS."
)
def _transaction_type_field_constraints(
transaction_type: TransactionType,
@@ -89,64 +93,147 @@ class Transaction(Base):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""
A unique identifier for the transaction.
Not used for anything else than identifying the transaction in the database.
"""
time: Mapped[datetime] = mapped_column(DateTime, unique=True)
"""
The time when the transaction took place.
This is used to order transactions chronologically, and to calculate
all kinds of state.
"""
message: Mapped[str | None] = mapped_column(Text, nullable=True)
"""
A message that can be set by the user to describe the reason
behind the transaction (or potentially a place to write som fan fiction).
This is not used for any calculations, but can be useful for debugging.
"""
# The type of transaction
type_: Mapped[TransactionType] = mapped_column(SQLEnum(TransactionType), name="type")
"""
Which type of transaction this is.
# TODO: this should be inferred
# If buying products, is the user penalized for having too low credit?
# penalty: Mapped[Boolean] = mapped_column(Boolean, default=False)
The type determines which fields are expected to be set.
"""
# The amount of money being added or subtracted from the user's credit
# This amount means different things depending on the transaction type:
# - ADJUST_BALANCE: The amount of credit to add or subtract from the user's balance
# - ADJUST_STOCK: The amount of money which disappeared with this stock adjustment
# (i.e. current price * product_count)
# - TRANSFER: The amount of credit to transfer to another user
# - ADD_PRODUCT: The real amount spent on the products
# (i.e. not per_product * product_count, which should be rounded up)
# - BUY_PRODUCT: The amount of credit spent on the product
amount: Mapped[int] = mapped_column(Integer)
amount: Mapped[int | None] = mapped_column(Integer)
"""
This field means different things depending on the transaction type:
- `ADD_PRODUCT`: The real amount spent on the products.
- `ADJUST_BALANCE`: The amount of credit to add or subtract from the user's balance.
- `BUY_PRODUCT`: The amount of credit spent on the product.
Note that this includes any penalties and interest that the user
had to pay as well.
- `TRANSFER`: The amount of balance to transfer to another user.
"""
# If adding products, how much is each product worth
per_product: Mapped[int | None] = mapped_column(Integer)
"""
If adding products, how much is each product worth
# The user who performs the transaction
user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
user: Mapped[User | None] = relationship(
Note that this is distinct from the total amount of the transaction,
because this gets rounded up to the nearest integer, while the total amount
that the user paid in the store would be stored in the `amount` field.
"""
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
"""The user who performs the transaction. See `user` for more details."""
user: Mapped[User] = relationship(
lazy="joined",
foreign_keys=[user_id],
)
"""
The user who performs the transaction.
For some transaction types, like `TRANSFER` and `ADD_PRODUCT`, this is a
functional field with "real world consequences" for price calculations.
For others, like `ADJUST_PENALTY` and `ADJUST_STOCK`, this is just a record of who
performed the transaction, and does not affect any state calculations.
"""
# Receiving user when moving credit from one user to another
transfer_user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"))
"""The user who receives money in a `TRANSFER` transaction."""
transfer_user: Mapped[User | None] = relationship(
lazy="joined",
foreign_keys=[transfer_user_id],
)
"""The user who receives money in a `TRANSFER` transaction."""
# The product that is either being added or bought
product_id: Mapped[int | None] = mapped_column(ForeignKey("product.id"))
"""The product being added or bought."""
product: Mapped[Product | None] = relationship(lazy="joined")
"""The product being added or bought."""
# The amount of products being added or bought
product_count: Mapped[int | None] = mapped_column(Integer)
"""
The amount of products being added or bought.
"""
penalty_threshold: Mapped[int | None] = mapped_column(Integer, nullable=True)
"""
On `ADJUST_PENALTY` transactions, this is the threshold in krs for when the user
should start getting penalized for low credit.
See also `penalty_multiplier`.
"""
penalty_multiplier_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
"""
On `ADJUST_PENALTY` transactions, this is the multiplier for the amount of
money the user has to pay when they have too low credit.
The multiplier is a percentage, so `100` means the user has to pay the full
price of the product, `200` means they have to pay double, etc.
See also `penalty_threshold`.
"""
# TODO: this should be inferred
# Assuming this is a BUY_PRODUCT transaction, was the user penalized for having
# too low credit in this transaction?
# is_penalized: Mapped[Boolean] = mapped_column(Boolean, default=False)
interest_rate_percent: Mapped[int | None] = mapped_column(Integer, nullable=True)
"""
On `ADJUST_INTEREST` transactions, this is the interest rate in percent
that the user has to pay on their balance.
The interest rate is a percentage, so `100` means the user has to pay the full
price of the product, `200` means they have to pay double, etc.
"""
def __init__(
self: Self,
type_: TransactionType,
user_id: int,
amount: int,
amount: int | None = None,
time: datetime | None = None,
message: str | None = None,
product_id: int | None = None,
transfer_user_id: int | None = None,
per_product: int | None = None,
product_count: int | None = None,
# penalty: bool = False
penalty_threshold: int | None = None,
penalty_multiplier_percent: int | None = None,
interest_rate_percent: int | None = None,
) -> None:
"""
Please do not call this constructor directly, use the factory methods instead.
"""
if time is None:
time = datetime.now()
@@ -159,14 +246,16 @@ class Transaction(Base):
self.transfer_user_id = transfer_user_id
self.per_product = per_product
self.product_count = product_count
# self.penalty = penalty
self.penalty_threshold = penalty_threshold
self.penalty_multiplier_percent = penalty_multiplier_percent
self.interest_rate_percent = interest_rate_percent
self._validate_by_transaction_type()
def _validate_by_transaction_type(self: Self) -> None:
"""
Validates the transaction based on its type.
Raises ValueError if the transaction is invalid.
Validates the transaction's fields based on its type.
Raises `ValueError` if the transaction is invalid.
"""
# TODO: do we allow free products?
if self.amount == 0:
@@ -186,6 +275,7 @@ class Transaction(Base):
if (
self.per_product is not None
and self.product_count is not None
and self.amount is not None
and self.amount > self.per_product * self.product_count
):
raise ValueError(
@@ -204,9 +294,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates an ADJUST transaction.
"""
return cls(
time=time,
type_=TransactionType.ADJUST_BALANCE,
@@ -215,81 +302,58 @@ class Transaction(Base):
message=message,
)
@classmethod
def adjust_interest(
cls: type[Self],
interest_rate_percent: int,
user_id: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
return cls(
time=time,
type_=TransactionType.ADJUST_INTEREST,
interest_rate_percent=interest_rate_percent,
user_id=user_id,
message=message,
)
@classmethod
def adjust_penalty(
cls: type[Self],
penalty_multiplier_percent: int,
penalty_threshold: int,
user_id: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
return cls(
time=time,
type_=TransactionType.ADJUST_PENALTY,
penalty_multiplier_percent=penalty_multiplier_percent,
penalty_threshold=penalty_threshold,
user_id=user_id,
message=message,
)
@classmethod
def adjust_stock(
cls: type[Self],
amount: int,
user_id: int,
product_id: int,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates an ADJUST_STOCK transaction.
"""
return cls(
time=time,
type_=TransactionType.ADJUST_STOCK,
amount=amount,
user_id=user_id,
product_id=product_id,
product_count=product_count,
message=message,
)
@classmethod
def adjust_stock_auto_amount(
cls: type[Self],
sql_session: Session,
user_id: int,
product_id: int,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates an ADJUST_STOCK transaction with the amount automatically calculated based on the product's current price.
"""
from .Product import Product
product = sql_session.scalar(select(Product).where(Product.id == product_id))
if product is None:
raise ValueError(f"Product with id {product_id} does not exist.")
price = product.price(sql_session)
return cls(
time=time,
type_=TransactionType.ADJUST_STOCK,
amount=price * product_count,
user_id=user_id,
product_id=product_id,
product_count=product_count,
message=message,
)
@classmethod
def transfer(
cls: type[Self],
amount: int,
user_id: int,
transfer_user_id: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates a TRANSFER transaction.
"""
return cls(
time=time,
type_=TransactionType.TRANSFER,
amount=amount,
user_id=user_id,
transfer_user_id=transfer_user_id,
message=message,
)
@classmethod
def add_product(
cls: type[Self],
@@ -301,9 +365,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates an ADD_PRODUCT transaction.
"""
return cls(
time=time,
type_=TransactionType.ADD_PRODUCT,
@@ -325,9 +386,6 @@ class Transaction(Base):
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates a BUY_PRODUCT transaction.
"""
return cls(
time=time,
type_=TransactionType.BUY_PRODUCT,
@@ -339,263 +397,19 @@ class Transaction(Base):
)
@classmethod
def buy_product_auto_amount(
def transfer(
cls: type[Self],
sql_session: Session,
amount: int,
user_id: int,
product_id: int,
product_count: int,
transfer_user_id: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates a BUY_PRODUCT transaction with the amount automatically calculated based on the product's current price.
"""
from .Product import Product
product = sql_session.scalar(select(Product).where(Product.id == product_id))
if product is None:
raise ValueError(f"Product with id {product_id} does not exist.")
price = product.price(sql_session)
return cls(
time=time,
type_=TransactionType.BUY_PRODUCT,
amount=price * product_count,
type_=TransactionType.TRANSFER,
amount=amount,
user_id=user_id,
product_id=product_id,
product_count=product_count,
transfer_user_id=transfer_user_id,
message=message,
)
############################
# USER BALANCE CALCULATION #
############################
@staticmethod
def _user_balance_query(
user: User,
# until: datetime | None = None,
):
"""
The inner query for calculating the user's balance.
This is used both directly via user_balance() and in Transaction CHECK constraints.
"""
balance_adjustments = (
select(func.coalesce(func.sum(Transaction.amount).label("balance_adjustments"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.ADJUST_BALANCE,
)
.scalar_subquery()
)
transfers_to_other_users = (
select(func.coalesce(func.sum(Transaction.amount).label("transfers_to_other_users"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.TRANSFER,
)
.scalar_subquery()
)
transfers_to_self = (
select(func.coalesce(func.sum(Transaction.amount).label("transfers_to_self"), 0))
.where(
Transaction.transfer_user_id == user.id,
Transaction.type_ == TransactionType.TRANSFER,
)
.scalar_subquery()
)
add_products = (
select(func.coalesce(func.sum(Transaction.amount).label("add_products"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.ADD_PRODUCT,
)
.scalar_subquery()
)
buy_products = (
select(func.coalesce(func.sum(Transaction.amount).label("buy_products"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.BUY_PRODUCT,
)
.scalar_subquery()
)
query = select(
# TODO: clearly define and fix the sign of the amount
(
0
+ balance_adjustments
- transfers_to_other_users
+ transfers_to_self
+ add_products
- buy_products
).label("credit")
)
return query
@staticmethod
def user_balance(
sql_session: Session,
user: User,
# Optional: calculate the balance until a certain transaction.
# until: Transaction | None = None,
) -> int:
"""
Calculates the balance of a user.
"""
query = Transaction._user_balance_query(user) # , until=until)
result = sql_session.scalar(query)
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
#############################
# PRODUCT PRICE CALCULATION #
#############################
@staticmethod
def _product_price_query(
product: Product,
# until: datetime | None = None,
):
"""
The inner query for calculating the product price.
This is used both directly via product_price() and in Transaction CHECK constraints.
"""
initial_element = select(
literal(0).label("i"),
literal(0).label("time"),
literal(0).label("price"),
literal(0).label("product_count"),
)
recursive_cte = initial_element.cte(name="rec_cte", 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.time,
Transaction.type_,
Transaction.product_count,
Transaction.per_product,
)
.where(
Transaction.type_.in_(
[
TransactionType.BUY_PRODUCT,
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
]
),
Transaction.product_id == product.id,
# TODO:
# If we have a transaction to limit the price calculation to, use it.
# If not, use all transactions for the product.
# (Transaction.time <= until.time) if until else True,
)
.order_by(Transaction.time.asc())
.alias("trx_subset")
)
recursive_elements = (
select(
trx_subset.c.i,
trx_subset.c.time,
case(
# Someone buys the product -> price remains the same.
(trx_subset.c.type_ == TransactionType.BUY_PRODUCT, 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,
cast(
func.ceil(
(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.min(recursive_cte.c.product_count, 0)
+ trx_subset.c.product_count
)
),
Integer,
),
),
# Someone adjusts the stock -> price remains the same.
(trx_subset.c.type_ == TransactionType.ADJUST_STOCK, 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,
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,
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,
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 + 1)
)
return recursive_cte.union_all(recursive_elements)
@staticmethod
def product_price(
sql_session: Session,
product: Product,
# Optional: calculate the price until a certain transaction.
# until: Transaction | None = None,
) -> int:
"""
Calculates the price of a product.
"""
recursive_cte = Transaction._product_price_query(product) # , until=until)
# 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.scalar(
select(recursive_cte.c.price).order_by(recursive_cte.c.i.desc()).limit(1)
)
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})."
)
return result

View File

@@ -6,8 +6,10 @@ class TransactionType(Enum):
Enum for transaction types.
"""
ADJUST_BALANCE = "adjust_balance"
ADJUST_STOCK = "adjust_stock"
TRANSFER = "transfer"
ADD_PRODUCT = "add_product"
ADJUST_BALANCE = "adjust_balance"
ADJUST_INTEREST = "adjust_interest"
ADJUST_PENALTY = "adjust_penalty"
ADJUST_STOCK = "adjust_stock"
BUY_PRODUCT = "buy_product"
TRANSFER = "transfer"

View File

@@ -1,6 +1,6 @@
from __future__ import annotations
from typing import Self
from typing import TYPE_CHECKING, Self
from sqlalchemy import (
Integer,
@@ -13,16 +13,21 @@ from sqlalchemy.orm import (
mapped_column,
)
import dibbler.models.Product as product
from .Base import Base
from .Transaction import Transaction
if TYPE_CHECKING:
from .Transaction import Transaction
class User(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
"""Internal database ID"""
name: Mapped[str] = mapped_column(String(20), unique=True)
"""
The PVV username of the user.
"""
card: Mapped[str | None] = mapped_column(String(20))
rfid: Mapped[str | None] = mapped_column(String(20))
@@ -41,31 +46,14 @@ class User(Base):
# def is_anonymous(self):
# return self.card == "11122233"
# TODO: rename to 'balance' everywhere
def credit(self, sql_session: Session) -> int:
"""
Returns the current credit of the user.
"""
result = Transaction.user_balance(
sql_session=sql_session,
user=self,
)
return result
def products(self, sql_session: Session) -> list[tuple[product.Product, int]]:
"""
Returns the products that the user has put into the system (and has not been purchased yet)
"""
...
# TODO: move to 'queries'
def transactions(self, sql_session: Session) -> list[Transaction]:
"""
Returns the transactions of the user in chronological order.
"""
from .Transaction import Transaction # Import here to avoid circular import
return list(
sql_session.scalars(
select(Transaction)

View File

@@ -5,7 +5,9 @@ from sqlalchemy.orm import Mapped, mapped_column
from dibbler.models import Base
# More like user balance cash money flow, amirite?
class UserBalanceCache(Base):
user_id: Mapped[int] = mapped_column(Integer, primary_key=True)
timestamp: Mapped[datetime] = mapped_column(DateTime)
balance: Mapped[int] = mapped_column(Integer)
timestamp: Mapped[datetime] = mapped_column(DateTime)

View File

View File

View File

@@ -0,0 +1,2 @@
# NOTE: this type of transaction should be password protected.
# the password can be set as a string literal in the config file.

View File

@@ -0,0 +1,2 @@
# NOTE: this type of transaction should be password protected.
# the password can be set as a string literal in the config file.

View File

@@ -0,0 +1,37 @@
from datetime import datetime
from sqlalchemy.orm import Session
from dibbler.models import (
Transaction,
TransactionType,
User,
Product,
)
from .product_price import product_price
def buy_product(
sql_session: Session,
user: User,
product: Product,
product_count: int,
time: datetime | None = None,
message: str | None = None,
) -> Transaction:
"""
Creates a BUY_PRODUCT transaction with the amount automatically calculated based on the product's current price.
"""
price = product_price(sql_session, product)
return Transaction(
time=time,
type_=TransactionType.BUY_PRODUCT,
amount=price * product_count,
user_id=user.id,
product_id=product.id,
product_count=product_count,
message=message,
)

View File

@@ -0,0 +1,181 @@
from datetime import datetime
from sqlalchemy import (
Integer,
asc,
case,
cast,
func,
literal,
select,
)
from sqlalchemy.orm import Session
from dibbler.models import (
Product,
Transaction,
TransactionType,
)
def _product_price_query(
product: Product,
# use_cache: bool = True,
# until: datetime | None = None,
):
"""
The inner query for calculating the product price.
"""
initial_element = select(
literal(0).label("i"),
literal(0).label("time"),
literal(0).label("price"),
literal(0).label("product_count"),
)
recursive_cte = initial_element.cte(name="rec_cte", 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.time,
Transaction.type_,
Transaction.product_count,
Transaction.per_product,
)
.where(
Transaction.type_.in_(
[
TransactionType.BUY_PRODUCT,
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
]
),
Transaction.product_id == product.id,
# TODO:
# If we have a transaction to limit the price calculation to, use it.
# If not, use all transactions for the product.
# (Transaction.time <= until.time) if until else True,
)
.order_by(Transaction.time.asc())
.alias("trx_subset")
)
recursive_elements = (
select(
trx_subset.c.i,
trx_subset.c.time,
case(
# Someone buys the product -> price remains the same.
(trx_subset.c.type_ == TransactionType.BUY_PRODUCT, 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,
cast(
func.ceil(
(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, 0)
+ trx_subset.c.product_count
)
),
Integer,
),
),
# Someone adjusts the stock -> price remains the same.
(trx_subset.c.type_ == TransactionType.ADJUST_STOCK, 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,
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,
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,
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 + 1)
)
return recursive_cte.union_all(recursive_elements)
def product_price_log(
sql_session: Session,
product: Product,
# use_cache: bool = True,
# Optional: calculate the price until a certain transaction.
# until: Transaction | None = None,
) -> list[tuple[int, datetime, int, int]]:
"""
Calculates the price of a product and returns a log of the price changes.
"""
recursive_cte = _product_price_query(product)
result = sql_session.execute(
select(
recursive_cte.c.i,
recursive_cte.c.time,
recursive_cte.c.price,
recursive_cte.c.product_count,
).order_by(recursive_cte.c.i.asc())
).all()
if not result:
# 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 [(row.i, row.time, row.price, row.product_count) for row in result]
@staticmethod
def product_price(
sql_session: Session,
product: Product,
# use_cache: bool = True,
# Optional: calculate the price until a certain transaction.
# until: Transaction | None = None,
) -> int:
"""
Calculates the price of a product.
"""
recursive_cte = _product_price_query(product) # , until=until)
# 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.scalar(
select(recursive_cte.c.price).order_by(recursive_cte.c.i.desc()).limit(1)
)
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})."
)
return result

View File

@@ -0,0 +1,52 @@
from sqlalchemy import case, func, select
from sqlalchemy.orm import Session
from dibbler.models import (
Product,
Transaction,
TransactionType,
)
def product_stock(
sql_session: Session,
product: Product,
# use_cache: bool = True,
# until: datetime | None = None,
) -> int:
"""
Returns the number of products in stock.
"""
result = sql_session.scalars(
select(
func.sum(
case(
(
Transaction.type_ == TransactionType.ADD_PRODUCT,
Transaction.product_count,
),
(
Transaction.type_ == TransactionType.BUY_PRODUCT,
-Transaction.product_count,
),
(
Transaction.type_ == TransactionType.ADJUST_STOCK,
Transaction.product_count,
),
else_=0,
)
)
).where(
Transaction.type_.in_(
[
TransactionType.BUY_PRODUCT,
TransactionType.ADD_PRODUCT,
TransactionType.ADJUST_STOCK,
]
),
Transaction.product_id == product.id,
)
).one_or_none()
return result or 0

View File

@@ -0,0 +1,53 @@
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from dibbler.models import Product
def search_product(
string: str,
session: Session,
find_hidden_products=True,
) -> Product | list[Product]:
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, not Product.hidden),
)
)
.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}%"), not Product.hidden),
)
)
.all()
)
return product_list

View File

@@ -0,0 +1,28 @@
from sqlalchemy import or_
from sqlalchemy.orm import Session
from dibbler.models import User
# TODO: modernize queries to use SQLAlchemy 2.0 style
def search_user(string: str, session: Session, ignorethisflag=None) -> User | list[User]:
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

View File

@@ -0,0 +1,102 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from dibbler.models import (
Transaction,
TransactionType,
User,
)
# TODO: rename to 'balance' everywhere
def _user_balance_query(
user: User,
# use_cache: bool = True,
# until: datetime | None = None,
):
"""
The inner query for calculating the user's balance.
"""
balance_adjustments = (
select(func.coalesce(func.sum(Transaction.amount).label("balance_adjustments"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.ADJUST_BALANCE,
)
.scalar_subquery()
)
transfers_to_other_users = (
select(func.coalesce(func.sum(Transaction.amount).label("transfers_to_other_users"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.TRANSFER,
)
.scalar_subquery()
)
transfers_to_self = (
select(func.coalesce(func.sum(Transaction.amount).label("transfers_to_self"), 0))
.where(
Transaction.transfer_user_id == user.id,
Transaction.type_ == TransactionType.TRANSFER,
)
.scalar_subquery()
)
add_products = (
select(func.coalesce(func.sum(Transaction.amount).label("add_products"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.ADD_PRODUCT,
)
.scalar_subquery()
)
buy_products = (
select(func.coalesce(func.sum(Transaction.amount).label("buy_products"), 0))
.where(
Transaction.user_id == user.id,
Transaction.type_ == TransactionType.BUY_PRODUCT,
)
.scalar_subquery()
)
query = select(
# TODO: clearly define and fix the sign of the amount
(
0
+ balance_adjustments
- transfers_to_other_users
+ transfers_to_self
+ add_products
- buy_products
).label("balance")
)
return query
def user_balance(
sql_session: Session,
user: User,
# use_cache: bool = True,
# Optional: calculate the balance until a certain transaction.
# until: Transaction | None = None,
) -> int:
"""
Calculates the balance of a user.
"""
query = _user_balance_query(user) # , until=until)
result = sql_session.scalar(query)
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

View File

View File

@@ -80,28 +80,3 @@ def main():
sql_session.add_all(transactions)
sql_session.commit()
# Note: These constructors depend on the content of the previous transactions,
# so they cannot be part of the initial transaction list.
transaction = Transaction.adjust_stock_auto_amount(
sql_session=sql_session,
time=datetime(2023, 10, 1, 12, 0, 2),
product_count=3,
user_id=user1.id,
product_id=product1.id,
)
sql_session.add(transaction)
sql_session.commit()
transaction = Transaction.adjust_stock_auto_amount(
sql_session=sql_session,
time=datetime(2023, 10, 1, 12, 0, 3),
product_count=-2,
user_id=user1.id,
product_id=product1.id,
)
sql_session.add(transaction)
sql_session.commit()