Compare commits
3 Commits
event-sour
...
kjhkjhkjh
| Author | SHA1 | Date | |
|---|---|---|---|
| 91f924a75a | |||
| e1605aab29 | |||
| 1164d492a3 |
3
.envrc
3
.envrc
@@ -1 +1,2 @@
|
|||||||
use flake
|
# devenv needs to know the path to the current working directory to create and manage mutable state
|
||||||
|
use flake . --no-pure-eval
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -1,11 +1,12 @@
|
|||||||
result
|
result
|
||||||
result-*
|
result-*
|
||||||
**/__pycache__
|
.venv
|
||||||
dibbler.egg-info
|
.direnv
|
||||||
|
.devenv
|
||||||
|
|
||||||
dist
|
dist
|
||||||
|
|
||||||
|
config.ini
|
||||||
test.db
|
test.db
|
||||||
|
|
||||||
.ruff_cache
|
.ruff_cache
|
||||||
|
|
||||||
.coverage
|
|
||||||
|
|||||||
59
README.md
59
README.md
@@ -2,48 +2,47 @@
|
|||||||
|
|
||||||
EDB-system for PVVVV
|
EDB-system for PVVVV
|
||||||
|
|
||||||
## Hva er dette?
|
|
||||||
|
|
||||||
Dibbler er et system laget av PVVere for PVVere for å byttelåne både matvarer og godis.
|
|
||||||
Det er designet for en gammeldags VT terminal, og er laget for å være enkelt både å bruke og å hacke på.
|
|
||||||
|
|
||||||
Programmet er skrevet i Python, og bruker en sql database for å lagre data.
|
|
||||||
|
|
||||||
Samlespleiseboden er satt opp slik at folk kjøper inn varer, og får dibblerkreditt, og så kan man bruke
|
|
||||||
denne kreditten til å kjøpe ut andre varer. Det er ikke noen form for authentisering, så hele systemet er basert på tillit.
|
|
||||||
Det er anbefalt å koble en barkodeleser til systemet for å gjøre det enklere å både legge til og kjøpe varer.
|
|
||||||
|
|
||||||
## Kom i gang
|
|
||||||
|
|
||||||
Installer python, og lag og aktiver et venv. Installer så avhengighetene med `pip install`.
|
|
||||||
|
|
||||||
Deretter kan du kjøre programmet med
|
|
||||||
|
|
||||||
```console
|
|
||||||
python -m dibbler -c example-config.ini create-db
|
|
||||||
python -m dibbler -c example-config.ini loop
|
|
||||||
```
|
|
||||||
|
|
||||||
## Nix
|
## Nix
|
||||||
|
|
||||||
### Bygge nytt image
|
### Hvordan kjøre
|
||||||
|
|
||||||
|
nix run github:Programvareverkstedet/dibbler
|
||||||
|
|
||||||
|
### Hvordan utvikle?
|
||||||
|
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/activate
|
||||||
|
pip install -e .
|
||||||
|
cp example-config.ini config.ini
|
||||||
|
dibbler -c config.ini create-db
|
||||||
|
dibbler -c config.ini loop
|
||||||
|
|
||||||
|
eller hvis du tolererer nix og postgres:
|
||||||
|
|
||||||
|
direnv allow # eller bare `nix develop`
|
||||||
|
devenv up
|
||||||
|
dibbler create-db
|
||||||
|
dibbler loop
|
||||||
|
|
||||||
|
### Bygge image
|
||||||
|
|
||||||
For å bygge et image trenger du en builder som takler å bygge for arkitekturen du skal lage et image for.
|
For å bygge et image trenger du en builder som takler å bygge for arkitekturen du skal lage et image for.
|
||||||
|
|
||||||
(Eller be til gudene om at cross compile funker)
|
_(Eller be til gudene om at cross compile funker)_
|
||||||
|
|
||||||
Flaket exposer en modul som autologger inn med en bruker som automatisk kjører dibbler, og setter opp et minimalistisk miljø.
|
Flaket exposer en modul som autologger inn med en bruker som automatisk kjører dibbler, og setter opp et minimalistisk miljø.
|
||||||
|
|
||||||
Før du bygger imaget burde du kopiere og endre `example-config.ini` lokalt til å inneholde instillingene dine. **NB: Denne kommer til å ligge i nix storen, ikke si noe her som du ikke vil at moren din skal høre.**
|
Før du bygger imaget burde du lage en `config.ini` fil lokalt som inneholder instillingene dine. **NB: Denne kommer til å ligge i nix storen.**
|
||||||
|
|
||||||
Du kan også endre hvilken config-fil som blir brukt direkte i pakken eller i modulen.
|
Du kan også endre hvilken `config.ini` som blir brukt direkte i pakken eller i modulen.
|
||||||
|
|
||||||
Se eksempelet for hvordan skrot er satt opp i `flake.nix` og `nix/skrott.nix`
|
Se eksempelet for hvordan skrot er satt opp i `flake.nix`
|
||||||
|
|
||||||
### Bygge image for skrot
|
### Bygge image for skrot
|
||||||
Skrot har et image definert i flake.nix:
|
|
||||||
|
|
||||||
1. endre `example-config.ini`
|
Skrot har et system image definert i `flake.nix`:
|
||||||
|
|
||||||
|
1. lag `config.ini` (`cp {example-,}config.ini`)
|
||||||
2. `nix build .#images.skrot`
|
2. `nix build .#images.skrot`
|
||||||
3. ???
|
3. ???
|
||||||
4. non-profit
|
4. non-profit!
|
||||||
|
|||||||
4
default.nix
Normal file
4
default.nix
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{ pkgs ? import <nixos-unstable> { } }:
|
||||||
|
{
|
||||||
|
dibbler = pkgs.callPackage ./nix/dibbler.nix { };
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from dibbler.conf import config
|
from dibbler.conf import config
|
||||||
|
|
||||||
engine = create_engine(config.get("database", "url"))
|
engine = create_engine(
|
||||||
|
os.environ.get("DIBBLER_DATABASE_URL")
|
||||||
|
or config.get("database", "url")
|
||||||
|
)
|
||||||
Session = sessionmaker(bind=engine)
|
Session = sessionmaker(bind=engine)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import os
|
|||||||
|
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from barcode.writer import ImageWriter, mm2px
|
from barcode.writer import ImageWriter, mm2px
|
||||||
from brother_ql.labels import ALL_LABELS
|
from brother_ql.devicedependent import label_type_specs
|
||||||
|
|
||||||
|
|
||||||
def px2mm(px, dpi=300):
|
def px2mm(px, dpi=300):
|
||||||
@@ -12,15 +12,14 @@ def px2mm(px, dpi=300):
|
|||||||
class BrotherLabelWriter(ImageWriter):
|
class BrotherLabelWriter(ImageWriter):
|
||||||
def __init__(self, typ="62", max_height=350, rot=False, text=None):
|
def __init__(self, typ="62", max_height=350, rot=False, text=None):
|
||||||
super(BrotherLabelWriter, self).__init__()
|
super(BrotherLabelWriter, self).__init__()
|
||||||
label = next([l for l in ALL_LABELS if l.identifier == typ])
|
assert typ in label_type_specs
|
||||||
assert label is not None
|
|
||||||
self.rot = rot
|
self.rot = rot
|
||||||
if self.rot:
|
if self.rot:
|
||||||
self._h, self._w = label.dots_printable
|
self._h, self._w = label_type_specs[typ]["dots_printable"]
|
||||||
if self._w == 0 or self._w > max_height:
|
if self._w == 0 or self._w > max_height:
|
||||||
self._w = min(max_height, self._h / 2)
|
self._w = min(max_height, self._h / 2)
|
||||||
else:
|
else:
|
||||||
self._w, self._h = label.dots_printable
|
self._w, self._h = label_type_specs[typ]["dots_printable"]
|
||||||
if self._h == 0 or self._h > max_height:
|
if self._h == 0 or self._h > max_height:
|
||||||
self._h = min(max_height, self._w / 2)
|
self._h = min(max_height, self._w / 2)
|
||||||
self._xo = 0.0
|
self._xo = 0.0
|
||||||
|
|||||||
@@ -1,7 +1,79 @@
|
|||||||
import os
|
|
||||||
import pwd
|
import pwd
|
||||||
import signal
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import os
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
def system_user_exists(username):
|
def system_user_exists(username):
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import os
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
import barcode
|
import barcode
|
||||||
from brother_ql.brother_ql_create import create_label
|
from brother_ql import BrotherQLRaster, create_label
|
||||||
from brother_ql.raster import BrotherQLRaster
|
|
||||||
from brother_ql.backends import backend_factory
|
from brother_ql.backends import backend_factory
|
||||||
from brother_ql.labels import ALL_LABELS
|
from brother_ql.devicedependent import label_type_specs
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
from .barcode_helpers import BrotherLabelWriter
|
from .barcode_helpers import BrotherLabelWriter
|
||||||
@@ -18,11 +17,10 @@ def print_name_label(
|
|||||||
label_type="62",
|
label_type="62",
|
||||||
printer_type="QL-700",
|
printer_type="QL-700",
|
||||||
):
|
):
|
||||||
label = next([l for l in ALL_LABELS if l.identifier == label_type])
|
|
||||||
if not rotate:
|
if not rotate:
|
||||||
width, height = label.dots_printable
|
width, height = label_type_specs[label_type]["dots_printable"]
|
||||||
else:
|
else:
|
||||||
height, width = label.dots_printable
|
height, width = label_type_specs[label_type]["dots_printable"]
|
||||||
|
|
||||||
font_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ChopinScript.ttf")
|
font_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ChopinScript.ttf")
|
||||||
fs = 2000
|
fs = 2000
|
||||||
|
|||||||
@@ -76,8 +76,12 @@ class Database:
|
|||||||
personDatoVerdi = defaultdict(list) # dict->array
|
personDatoVerdi = defaultdict(list) # dict->array
|
||||||
personUkedagVerdi = defaultdict(list)
|
personUkedagVerdi = defaultdict(list)
|
||||||
# for global
|
# for global
|
||||||
personPosTransactions = {} # personPosTransactions[trygvrad] == 100 #trygvrad har lagt 100kr i boksen
|
personPosTransactions = (
|
||||||
personNegTransactions = {} # personNegTransactions[trygvrad» == 70 #trygvrad har tatt 70kr fra boksen
|
{}
|
||||||
|
) # personPosTransactions[trygvrad] == 100 #trygvrad har lagt 100kr i boksen
|
||||||
|
personNegTransactions = (
|
||||||
|
{}
|
||||||
|
) # personNegTransactions[trygvrad» == 70 #trygvrad har tatt 70kr fra boksen
|
||||||
globalVareAntall = {} # globalVareAntall[Oreo] == 3
|
globalVareAntall = {} # globalVareAntall[Oreo] == 3
|
||||||
globalVareVerdi = {} # globalVareVerdi[Oreo] == 30 #[kr]
|
globalVareVerdi = {} # globalVareVerdi[Oreo] == 30 #[kr]
|
||||||
globalPersonAntall = {} # globalPersonAntall[trygvrad] == 3
|
globalPersonAntall = {} # globalPersonAntall[trygvrad] == 3
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
from dibbler.conf import config
|
from dibbler.conf import config
|
||||||
|
|
||||||
@@ -10,6 +12,7 @@ parser.add_argument(
|
|||||||
help="Path to the config file",
|
help="Path to the config file",
|
||||||
type=str,
|
type=str,
|
||||||
required=False,
|
required=False,
|
||||||
|
default=os.environ.get("DIBBLER_CONFIG_FILE", None)
|
||||||
)
|
)
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(
|
subparsers = parser.add_subparsers(
|
||||||
@@ -20,11 +23,12 @@ subparsers = parser.add_subparsers(
|
|||||||
subparsers.add_parser("loop", help="Run the dibbler loop")
|
subparsers.add_parser("loop", help="Run the dibbler loop")
|
||||||
subparsers.add_parser("create-db", help="Create the database")
|
subparsers.add_parser("create-db", help="Create the database")
|
||||||
subparsers.add_parser("slabbedasker", help="Find out who is slabbedasker")
|
subparsers.add_parser("slabbedasker", help="Find out who is slabbedasker")
|
||||||
subparsers.add_parser("seed-data", help="Fill with mock data")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if args.config is None:
|
||||||
|
print("ERROR: no config was provided", file=sys.stderr)
|
||||||
config.read(args.config)
|
config.read(args.config)
|
||||||
|
|
||||||
if args.subcommand == "loop":
|
if args.subcommand == "loop":
|
||||||
@@ -42,11 +46,6 @@ def main():
|
|||||||
|
|
||||||
slabbedasker.main()
|
slabbedasker.main()
|
||||||
|
|
||||||
elif args.subcommand == "seed-data":
|
|
||||||
import dibbler.subcommands.seed_test_data as seed_test_data
|
|
||||||
|
|
||||||
seed_test_data.main()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ When finished, write an empty line to confirm the purchase.\n"""
|
|||||||
print(f"User {t.user.name}'s credit is now {t.user.credit:d} kr")
|
print(f"User {t.user.name}'s credit is now {t.user.credit:d} kr")
|
||||||
if t.user.credit < config.getint("limits", "low_credit_warning_limit"):
|
if t.user.credit < config.getint("limits", "low_credit_warning_limit"):
|
||||||
print(
|
print(
|
||||||
f"USER {t.user.name} HAS LOWER CREDIT THAN {config.getint('limits', 'low_credit_warning_limit'):d},",
|
f'USER {t.user.name} HAS LOWER CREDIT THAN {config.getint("limits", "low_credit_warning_limit"):d},',
|
||||||
"AND SHOULD CONSIDER PUTTING SOME MONEY IN THE BOX.",
|
"AND SHOULD CONSIDER PUTTING SOME MONEY IN THE BOX.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ from sqlalchemy.orm.collections import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _pascal_case_to_snake_case(name: str) -> str:
|
|
||||||
return "".join(["_" + i.lower() if i.isupper() else i for i in name]).lstrip("_")
|
|
||||||
|
|
||||||
|
|
||||||
class Base(DeclarativeBase):
|
class Base(DeclarativeBase):
|
||||||
metadata = MetaData(
|
metadata = MetaData(
|
||||||
naming_convention={
|
naming_convention={
|
||||||
"ix": "ix_%(table_name)s_%(column_0_label)s",
|
"ix": "ix_%(column_0_label)s",
|
||||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
"ck": "ck_%(table_name)s_`%(constraint_name)s`",
|
||||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||||
"pk": "pk_%(table_name)s",
|
"pk": "pk_%(table_name)s",
|
||||||
}
|
}
|
||||||
@@ -27,7 +23,7 @@ class Base(DeclarativeBase):
|
|||||||
|
|
||||||
@declared_attr.directive
|
@declared_attr.directive
|
||||||
def __tablename__(cls) -> str:
|
def __tablename__(cls) -> str:
|
||||||
return _pascal_case_to_snake_case(cls.__name__)
|
return cls.__name__
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
columns = ", ".join(
|
columns = ", ".join(
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Boolean,
|
Boolean,
|
||||||
@@ -10,44 +9,39 @@ from sqlalchemy import (
|
|||||||
from sqlalchemy.orm import (
|
from sqlalchemy.orm import (
|
||||||
Mapped,
|
Mapped,
|
||||||
mapped_column,
|
mapped_column,
|
||||||
|
relationship,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .Base import Base
|
from .Base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .PurchaseEntry import PurchaseEntry
|
||||||
|
from .UserProducts import UserProducts
|
||||||
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
__tablename__ = "products"
|
||||||
"""Internal database ID"""
|
|
||||||
|
|
||||||
bar_code: Mapped[str] = mapped_column(String(13), unique=True)
|
product_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
"""
|
bar_code: Mapped[str] = mapped_column(String(13))
|
||||||
The bar code of the product.
|
name: Mapped[str] = mapped_column(String(45))
|
||||||
|
price: Mapped[int] = mapped_column(Integer)
|
||||||
|
stock: Mapped[int] = mapped_column(Integer)
|
||||||
|
hidden: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
This is a unique identifier for the product, typically a 13-digit
|
purchases: Mapped[set[PurchaseEntry]] = relationship(back_populates="product")
|
||||||
EAN-13 code.
|
users: Mapped[set[UserProducts]] = relationship(back_populates="product")
|
||||||
"""
|
|
||||||
|
|
||||||
name: Mapped[str] = mapped_column(String(45), unique=True)
|
bar_code_re = r"[0-9]+"
|
||||||
"""
|
name_re = r".+"
|
||||||
The name of the product.
|
name_length = 45
|
||||||
|
|
||||||
Please don't write fanfics here, this is not a place for that.
|
def __init__(self, bar_code, name, price, stock=0, hidden=False):
|
||||||
"""
|
|
||||||
|
|
||||||
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,
|
|
||||||
bar_code: str,
|
|
||||||
name: str,
|
|
||||||
hidden: bool = False,
|
|
||||||
) -> None:
|
|
||||||
self.bar_code = bar_code
|
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self.bar_code = bar_code
|
||||||
|
self.price = price
|
||||||
|
self.stock = stock
|
||||||
self.hidden = hidden
|
self.hidden = hidden
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Integer, DateTime
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from dibbler.models import Base
|
|
||||||
|
|
||||||
class ProductCache(Base):
|
|
||||||
product_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
||||||
|
|
||||||
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)
|
|
||||||
70
dibbler/models/Purchase.py
Normal file
70
dibbler/models/Purchase.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
import math
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
DateTime,
|
||||||
|
Integer,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import (
|
||||||
|
Mapped,
|
||||||
|
mapped_column,
|
||||||
|
relationship,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .Base import Base
|
||||||
|
from .Transaction import Transaction
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .PurchaseEntry import PurchaseEntry
|
||||||
|
|
||||||
|
|
||||||
|
class Purchase(Base):
|
||||||
|
__tablename__ = "purchases"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
time: Mapped[datetime] = mapped_column(DateTime)
|
||||||
|
price: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
transactions: Mapped[set[Transaction]] = relationship(
|
||||||
|
back_populates="purchase", order_by="Transaction.user_name"
|
||||||
|
)
|
||||||
|
entries: Mapped[set[PurchaseEntry]] = relationship(back_populates="purchase")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_complete(self):
|
||||||
|
return len(self.transactions) > 0 and len(self.entries) > 0
|
||||||
|
|
||||||
|
def price_per_transaction(self, round_up=True):
|
||||||
|
if round_up:
|
||||||
|
return int(math.ceil(float(self.price) / len(self.transactions)))
|
||||||
|
else:
|
||||||
|
return int(math.floor(float(self.price) / len(self.transactions)))
|
||||||
|
|
||||||
|
def set_price(self, round_up=True):
|
||||||
|
self.price = 0
|
||||||
|
for entry in self.entries:
|
||||||
|
self.price += entry.amount * entry.product.price
|
||||||
|
if len(self.transactions) > 0:
|
||||||
|
for t in self.transactions:
|
||||||
|
t.amount = self.price_per_transaction(round_up=round_up)
|
||||||
|
|
||||||
|
def perform_purchase(self, ignore_penalty=False, round_up=True):
|
||||||
|
self.time = datetime.datetime.now()
|
||||||
|
self.set_price(round_up=round_up)
|
||||||
|
for t in self.transactions:
|
||||||
|
t.perform_transaction(ignore_penalty=ignore_penalty)
|
||||||
|
for entry in self.entries:
|
||||||
|
entry.product.stock -= entry.amount
|
||||||
|
|
||||||
|
def perform_soft_purchase(self, price, round_up=True):
|
||||||
|
self.time = datetime.datetime.now()
|
||||||
|
self.price = price
|
||||||
|
for t in self.transactions:
|
||||||
|
t.amount = self.price_per_transaction(round_up=round_up)
|
||||||
|
for t in self.transactions:
|
||||||
|
t.perform_transaction()
|
||||||
37
dibbler/models/PurchaseEntry.py
Normal file
37
dibbler/models/PurchaseEntry.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Integer,
|
||||||
|
ForeignKey,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import (
|
||||||
|
Mapped,
|
||||||
|
mapped_column,
|
||||||
|
relationship,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .Base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .Product import Product
|
||||||
|
from .Purchase import Purchase
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseEntry(Base):
|
||||||
|
__tablename__ = "purchase_entries"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
amount: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
product_id: Mapped[int] = mapped_column(ForeignKey("products.product_id"))
|
||||||
|
purchase_id: Mapped[int] = mapped_column(ForeignKey("purchases.id"))
|
||||||
|
|
||||||
|
product: Mapped[Product] = relationship(lazy="joined")
|
||||||
|
purchase: Mapped[Purchase] = relationship(lazy="joined")
|
||||||
|
|
||||||
|
def __init__(self, purchase, product, amount):
|
||||||
|
self.product = product
|
||||||
|
self.product_bar_code = product.bar_code
|
||||||
|
self.purchase = purchase
|
||||||
|
self.amount = amount
|
||||||
@@ -1,463 +1,52 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Self
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
CheckConstraint,
|
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
Text,
|
String,
|
||||||
and_,
|
|
||||||
column,
|
|
||||||
or_,
|
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import (
|
from sqlalchemy.orm import (
|
||||||
Mapped,
|
Mapped,
|
||||||
mapped_column,
|
mapped_column,
|
||||||
relationship,
|
relationship,
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm.collections import (
|
|
||||||
InstrumentedDict,
|
|
||||||
InstrumentedList,
|
|
||||||
InstrumentedSet,
|
|
||||||
)
|
|
||||||
from sqlalchemy.sql.schema import Index
|
|
||||||
|
|
||||||
from .Base import Base
|
from .Base import Base
|
||||||
from .TransactionType import TransactionType, TransactionTypeSQL
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .Product import Product
|
|
||||||
from .User import User
|
from .User import User
|
||||||
|
from .Purchase import Purchase
|
||||||
# TODO: rename to *_PERCENT
|
|
||||||
|
|
||||||
# NOTE: these only matter when there are no adjustments made in the database.
|
|
||||||
DEFAULT_INTEREST_RATE_PERCENTAGE = 100
|
|
||||||
DEFAULT_PENALTY_THRESHOLD = -100
|
|
||||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE = 200
|
|
||||||
|
|
||||||
# TODO: allow for joint transactions?
|
|
||||||
# dibbler allows joint transactions (e.g. buying more than one product at once, several people buying the same product, etc.)
|
|
||||||
# instead of having the software split the transactions up, making them hard to reconnect,
|
|
||||||
# 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",
|
|
||||||
"product_count",
|
|
||||||
"product_id",
|
|
||||||
"transfer_user_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
_EXPECTED_FIELDS: dict[TransactionType, set[str]] = {
|
|
||||||
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"},
|
|
||||||
TransactionType.BUY_PRODUCT: {"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,
|
|
||||||
expected_fields: set[str],
|
|
||||||
) -> CheckConstraint:
|
|
||||||
unexpected_fields = _DYNAMIC_FIELDS - expected_fields
|
|
||||||
|
|
||||||
return CheckConstraint(
|
|
||||||
or_(
|
|
||||||
column("type") != transaction_type.value,
|
|
||||||
and_(
|
|
||||||
*[column(field) != None for field in expected_fields],
|
|
||||||
*[column(field) == None for field in unexpected_fields],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
name=f"trx_type_{transaction_type.value}_expected_fields",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Transaction(Base):
|
class Transaction(Base):
|
||||||
__table_args__ = (
|
__tablename__ = "transactions"
|
||||||
*[
|
|
||||||
_transaction_type_field_constraints(transaction_type, expected_fields)
|
|
||||||
for transaction_type, expected_fields in _EXPECTED_FIELDS.items()
|
|
||||||
],
|
|
||||||
CheckConstraint(
|
|
||||||
or_(
|
|
||||||
column("type") != TransactionType.TRANSFER.value,
|
|
||||||
column("user_id") != column("transfer_user_id"),
|
|
||||||
),
|
|
||||||
name="trx_type_transfer_no_self_transfers",
|
|
||||||
),
|
|
||||||
# Speed up product count calculation
|
|
||||||
Index("product_user_time", "product_id", "user_id", "time"),
|
|
||||||
# Speed up product owner calculation
|
|
||||||
Index("user_product_time", "user_id", "product_id", "time"),
|
|
||||||
# Speed up user transaction list / credit calculation
|
|
||||||
Index("user_time", "user_id", "time"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
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)
|
||||||
"""
|
amount: Mapped[int] = mapped_column(Integer)
|
||||||
|
penalty: Mapped[int] = mapped_column(Integer)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
|
||||||
time: Mapped[datetime] = mapped_column(DateTime, unique=True)
|
user_name: Mapped[str] = mapped_column(ForeignKey("users.name"))
|
||||||
"""
|
purchase_id: Mapped[int | None] = mapped_column(ForeignKey("purchases.id"))
|
||||||
The time when the transaction took place.
|
|
||||||
|
|
||||||
This is used to order transactions chronologically, and to calculate
|
user: Mapped[User] = relationship(lazy="joined")
|
||||||
all kinds of state.
|
purchase: Mapped[Purchase] = relationship(lazy="joined")
|
||||||
"""
|
|
||||||
|
|
||||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
def __init__(self, user, amount=0, description=None, purchase=None, penalty=1):
|
||||||
"""
|
self.user = user
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type")
|
|
||||||
"""
|
|
||||||
Which type of transaction this is.
|
|
||||||
|
|
||||||
The type determines which fields are expected to be set.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
per_product: Mapped[int | None] = mapped_column(Integer)
|
|
||||||
"""
|
|
||||||
If adding products, how much is each product worth
|
|
||||||
|
|
||||||
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 | 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_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()
|
|
||||||
|
|
||||||
self.time = time
|
|
||||||
self.message = message
|
|
||||||
self.type_ = type_
|
|
||||||
self.amount = amount
|
self.amount = amount
|
||||||
self.user_id = user_id
|
self.description = description
|
||||||
self.product_id = product_id
|
self.purchase = purchase
|
||||||
self.transfer_user_id = transfer_user_id
|
self.penalty = penalty
|
||||||
self.per_product = per_product
|
|
||||||
self.product_count = product_count
|
|
||||||
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 perform_transaction(self, ignore_penalty=False):
|
||||||
|
self.time = datetime.datetime.now()
|
||||||
def _validate_by_transaction_type(self: Self) -> None:
|
if not ignore_penalty:
|
||||||
"""
|
self.amount *= self.penalty
|
||||||
Validates the transaction's fields based on its type.
|
self.user.credit -= self.amount
|
||||||
Raises `ValueError` if the transaction is invalid.
|
|
||||||
"""
|
|
||||||
# TODO: do we allow free products?
|
|
||||||
if self.amount == 0:
|
|
||||||
raise ValueError("Amount must not be zero.")
|
|
||||||
|
|
||||||
for field in _EXPECTED_FIELDS[self.type_]:
|
|
||||||
if getattr(self, field) is None:
|
|
||||||
raise ValueError(f"{field} must not be None for {self.type_.value} transactions.")
|
|
||||||
|
|
||||||
for field in _DYNAMIC_FIELDS - _EXPECTED_FIELDS[self.type_]:
|
|
||||||
if getattr(self, field) is not None:
|
|
||||||
raise ValueError(f"{field} must be None for {self.type_.value} transactions.")
|
|
||||||
|
|
||||||
if self.per_product is not None and self.per_product <= 0:
|
|
||||||
raise ValueError("per_product must be greater than zero.")
|
|
||||||
|
|
||||||
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(
|
|
||||||
"The real amount of the transaction must be less than the total value of the products."
|
|
||||||
)
|
|
||||||
|
|
||||||
# TODO: improve printing further
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
|
||||||
sort_order = [
|
|
||||||
"id",
|
|
||||||
"time",
|
|
||||||
]
|
|
||||||
|
|
||||||
columns = ", ".join(
|
|
||||||
f"{k}={repr(v)}"
|
|
||||||
for k, v in sorted(
|
|
||||||
self.__dict__.items(),
|
|
||||||
key=lambda item: chr(sort_order.index(item[0]))
|
|
||||||
if item[0] in sort_order
|
|
||||||
else item[0],
|
|
||||||
)
|
|
||||||
if not any(
|
|
||||||
[
|
|
||||||
k == "type_",
|
|
||||||
(k == "message" and v is None),
|
|
||||||
k.startswith("_"),
|
|
||||||
# Ensure that we don't try to print out the entire list of
|
|
||||||
# relationships, which could create an infinite loop
|
|
||||||
isinstance(v, Base),
|
|
||||||
isinstance(v, InstrumentedList),
|
|
||||||
isinstance(v, InstrumentedSet),
|
|
||||||
isinstance(v, InstrumentedDict),
|
|
||||||
*[k in (_DYNAMIC_FIELDS - _EXPECTED_FIELDS[self.type_])],
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return f"{self.type_.upper()}({columns})"
|
|
||||||
|
|
||||||
###################
|
|
||||||
# FACTORY METHODS #
|
|
||||||
###################
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def adjust_balance(
|
|
||||||
cls: type[Self],
|
|
||||||
amount: int,
|
|
||||||
user_id: int,
|
|
||||||
time: datetime | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
) -> Transaction:
|
|
||||||
return cls(
|
|
||||||
time=time,
|
|
||||||
type_=TransactionType.ADJUST_BALANCE,
|
|
||||||
amount=amount,
|
|
||||||
user_id=user_id,
|
|
||||||
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],
|
|
||||||
user_id: int,
|
|
||||||
product_id: int,
|
|
||||||
product_count: int,
|
|
||||||
time: datetime | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
) -> Transaction:
|
|
||||||
return cls(
|
|
||||||
time=time,
|
|
||||||
type_=TransactionType.ADJUST_STOCK,
|
|
||||||
user_id=user_id,
|
|
||||||
product_id=product_id,
|
|
||||||
product_count=product_count,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def add_product(
|
|
||||||
cls: type[Self],
|
|
||||||
amount: int,
|
|
||||||
user_id: int,
|
|
||||||
product_id: int,
|
|
||||||
per_product: int,
|
|
||||||
product_count: int,
|
|
||||||
time: datetime | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
) -> Transaction:
|
|
||||||
return cls(
|
|
||||||
time=time,
|
|
||||||
type_=TransactionType.ADD_PRODUCT,
|
|
||||||
amount=amount,
|
|
||||||
user_id=user_id,
|
|
||||||
product_id=product_id,
|
|
||||||
per_product=per_product,
|
|
||||||
product_count=product_count,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def buy_product(
|
|
||||||
cls: type[Self],
|
|
||||||
user_id: int,
|
|
||||||
product_id: int,
|
|
||||||
product_count: int,
|
|
||||||
time: datetime | None = None,
|
|
||||||
message: str | None = None,
|
|
||||||
) -> Transaction:
|
|
||||||
return cls(
|
|
||||||
time=time,
|
|
||||||
type_=TransactionType.BUY_PRODUCT,
|
|
||||||
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:
|
|
||||||
return cls(
|
|
||||||
time=time,
|
|
||||||
type_=TransactionType.TRANSFER,
|
|
||||||
amount=amount,
|
|
||||||
user_id=user_id,
|
|
||||||
transfer_user_id=transfer_user_id,
|
|
||||||
message=message,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
from enum import StrEnum, auto
|
|
||||||
|
|
||||||
from sqlalchemy import Enum as SQLEnum
|
|
||||||
|
|
||||||
|
|
||||||
class TransactionType(StrEnum):
|
|
||||||
"""
|
|
||||||
Enum for transaction types.
|
|
||||||
"""
|
|
||||||
|
|
||||||
ADD_PRODUCT = auto()
|
|
||||||
ADJUST_BALANCE = auto()
|
|
||||||
ADJUST_INTEREST = auto()
|
|
||||||
ADJUST_PENALTY = auto()
|
|
||||||
ADJUST_STOCK = auto()
|
|
||||||
BUY_PRODUCT = auto()
|
|
||||||
TRANSFER = auto()
|
|
||||||
|
|
||||||
|
|
||||||
TransactionTypeSQL = SQLEnum(
|
|
||||||
TransactionType,
|
|
||||||
native_enum=True,
|
|
||||||
create_constraint=True,
|
|
||||||
validate_strings=True,
|
|
||||||
values_callable=lambda x: [i.value for i in x],
|
|
||||||
)
|
|
||||||
@@ -1,47 +1,49 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from typing import TYPE_CHECKING, Self
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Integer,
|
Integer,
|
||||||
String,
|
String,
|
||||||
select,
|
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import (
|
from sqlalchemy.orm import (
|
||||||
Mapped,
|
Mapped,
|
||||||
Session,
|
|
||||||
mapped_column,
|
mapped_column,
|
||||||
|
relationship,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .Base import Base
|
from .Base import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from .UserProducts import UserProducts
|
||||||
from .Transaction import Transaction
|
from .Transaction import Transaction
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
__tablename__ = "users"
|
||||||
"""Internal database ID"""
|
name: Mapped[str] = mapped_column(String(10), primary_key=True)
|
||||||
|
credit: Mapped[str] = mapped_column(Integer)
|
||||||
name: Mapped[str] = mapped_column(String(20), unique=True)
|
|
||||||
"""
|
|
||||||
The PVV username of the user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
card: Mapped[str | None] = mapped_column(String(20))
|
card: Mapped[str | None] = mapped_column(String(20))
|
||||||
rfid: Mapped[str | None] = mapped_column(String(20))
|
rfid: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
|
||||||
# name_re = r"[a-z]+"
|
products: Mapped[set[UserProducts]] = relationship(back_populates="user")
|
||||||
# card_re = r"(([Nn][Tt][Nn][Uu])?[0-9]+)?"
|
transactions: Mapped[set[Transaction]] = relationship(back_populates="user")
|
||||||
# rfid_re = r"[0-9a-fA-F]*"
|
|
||||||
|
|
||||||
def __init__(self: Self, name: str, card: str | None = None, rfid: str | None = None) -> None:
|
name_re = r"[a-z]+"
|
||||||
|
card_re = r"(([Nn][Tt][Nn][Uu])?[0-9]+)?"
|
||||||
|
rfid_re = r"[0-9a-fA-F]*"
|
||||||
|
|
||||||
|
def __init__(self, name, card, rfid=None, credit=0):
|
||||||
self.name = name
|
self.name = name
|
||||||
|
if card == "":
|
||||||
|
card = None
|
||||||
self.card = card
|
self.card = card
|
||||||
|
if rfid == "":
|
||||||
|
rfid = None
|
||||||
self.rfid = rfid
|
self.rfid = rfid
|
||||||
|
self.credit = credit
|
||||||
|
|
||||||
# def __str__(self):
|
def __str__(self):
|
||||||
# return self.name
|
return self.name
|
||||||
|
|
||||||
# def is_anonymous(self):
|
def is_anonymous(self):
|
||||||
# return self.card == "11122233"
|
return self.card == "11122233"
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import Integer, DateTime
|
|
||||||
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)
|
|
||||||
|
|
||||||
balance: Mapped[int] = mapped_column(Integer)
|
|
||||||
timestamp: Mapped[datetime] = mapped_column(DateTime)
|
|
||||||
31
dibbler/models/UserProducts.py
Normal file
31
dibbler/models/UserProducts.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Integer,
|
||||||
|
ForeignKey,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import (
|
||||||
|
Mapped,
|
||||||
|
mapped_column,
|
||||||
|
relationship,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .Base import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .User import User
|
||||||
|
from .Product import Product
|
||||||
|
|
||||||
|
|
||||||
|
class UserProducts(Base):
|
||||||
|
__tablename__ = "user_products"
|
||||||
|
|
||||||
|
user_name: Mapped[str] = mapped_column(ForeignKey("users.name"), primary_key=True)
|
||||||
|
product_id: Mapped[int] = mapped_column(ForeignKey("products.product_id"), primary_key=True)
|
||||||
|
|
||||||
|
count: Mapped[int] = mapped_column(Integer)
|
||||||
|
sign: Mapped[int] = mapped_column(Integer)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
|
product: Mapped[Product] = relationship()
|
||||||
@@ -1,12 +1,17 @@
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
'Base',
|
||||||
"Product",
|
'Product',
|
||||||
"Transaction",
|
'Purchase',
|
||||||
"User",
|
'PurchaseEntry',
|
||||||
|
'Transaction',
|
||||||
|
'User',
|
||||||
|
'UserProducts',
|
||||||
]
|
]
|
||||||
|
|
||||||
from .Base import Base
|
from .Base import Base
|
||||||
from .Product import Product
|
from .Product import Product
|
||||||
|
from .Purchase import Purchase
|
||||||
|
from .PurchaseEntry import PurchaseEntry
|
||||||
from .Transaction import Transaction
|
from .Transaction import Transaction
|
||||||
from .TransactionType import TransactionType
|
|
||||||
from .User import User
|
from .User import User
|
||||||
|
from .UserProducts import UserProducts
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
# NOTE: this type of transaction should be password protected.
|
|
||||||
# the password can be set as a string literal in the config file.
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
# NOTE: this type of transaction should be password protected.
|
|
||||||
# the password can be set as a string literal in the config file.
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Transaction, TransactionType
|
|
||||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
return result.interest_rate_percent
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
import math
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
ColumnElement,
|
|
||||||
Integer,
|
|
||||||
SQLColumnExpression,
|
|
||||||
asc,
|
|
||||||
case,
|
|
||||||
cast,
|
|
||||||
func,
|
|
||||||
literal,
|
|
||||||
select,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
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: datetime | SQLColumnExpression[datetime] | None = None,
|
|
||||||
until_including: 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.")
|
|
||||||
|
|
||||||
initial_element = select(
|
|
||||||
literal(0).label("i"),
|
|
||||||
literal(0).label("time"),
|
|
||||||
literal(None).label("transaction_id"),
|
|
||||||
literal(0).label("price"),
|
|
||||||
literal(0).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,
|
|
||||||
TransactionType.ADD_PRODUCT,
|
|
||||||
TransactionType.ADJUST_STOCK,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
Transaction.product_id == product_id,
|
|
||||||
case(
|
|
||||||
(literal(until_including), Transaction.time <= until),
|
|
||||||
else_=Transaction.time < until,
|
|
||||||
)
|
|
||||||
if until is not None
|
|
||||||
else literal(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, 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(
|
|
||||||
(
|
|
||||||
recursive_cte.c.price * func.max(recursive_cte.c.product_count, 0)
|
|
||||||
+ 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)
|
|
||||||
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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(1)
|
|
||||||
).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,
|
|
||||||
literal(True) if until is None else Transaction.time <= until.time,
|
|
||||||
)
|
|
||||||
.order_by(Transaction.time.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
or DEFAULT_INTEREST_RATE_PERCENTAGE
|
|
||||||
)
|
|
||||||
result = math.ceil(result * interest_rate / 100)
|
|
||||||
|
|
||||||
return result
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import case, func, literal, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import (
|
|
||||||
Product,
|
|
||||||
Transaction,
|
|
||||||
TransactionType,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _product_stock_query(
|
|
||||||
product_id: int,
|
|
||||||
use_cache: bool = True,
|
|
||||||
until: datetime | None = None,
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
The inner query for calculating the product stock.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if use_cache:
|
|
||||||
print("WARNING: Using cache for product stock query is not implemented yet.")
|
|
||||||
|
|
||||||
query = 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,
|
|
||||||
Transaction.time <= until if until is not None else literal(True),
|
|
||||||
)
|
|
||||||
|
|
||||||
return query
|
|
||||||
|
|
||||||
|
|
||||||
def product_stock(
|
|
||||||
sql_session: Session,
|
|
||||||
product: Product,
|
|
||||||
use_cache: bool = True,
|
|
||||||
until: datetime | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Returns the number of products in stock.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
from sqlalchemy import and_, literal, or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product
|
|
||||||
|
|
||||||
|
|
||||||
def search_product(
|
|
||||||
string: str,
|
|
||||||
sql_session: Session,
|
|
||||||
find_hidden_products=True,
|
|
||||||
) -> 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)
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
from sqlalchemy import or_, select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import User
|
|
||||||
|
|
||||||
|
|
||||||
def search_user(
|
|
||||||
string: str,
|
|
||||||
sql_session: Session,
|
|
||||||
ignorethisflag=None,
|
|
||||||
) -> 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)
|
|
||||||
@@ -1,319 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import (
|
|
||||||
Float,
|
|
||||||
Integer,
|
|
||||||
and_,
|
|
||||||
asc,
|
|
||||||
case,
|
|
||||||
cast,
|
|
||||||
column,
|
|
||||||
func,
|
|
||||||
literal,
|
|
||||||
or_,
|
|
||||||
select,
|
|
||||||
)
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
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: int,
|
|
||||||
use_cache: bool = True,
|
|
||||||
until: datetime | None = None,
|
|
||||||
until_including: bool = True,
|
|
||||||
cte_name: str = "rec_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.")
|
|
||||||
|
|
||||||
initial_element = select(
|
|
||||||
literal(0).label("i"),
|
|
||||||
literal(0).label("time"),
|
|
||||||
literal(None).label("transaction_id"),
|
|
||||||
literal(0).label("balance"),
|
|
||||||
literal(DEFAULT_INTEREST_RATE_PERCENTAGE).label("interest_rate_percent"),
|
|
||||||
literal(DEFAULT_PENALTY_THRESHOLD).label("penalty_threshold"),
|
|
||||||
literal(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=asc(Transaction.time)).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,
|
|
||||||
TransactionType.ADJUST_BALANCE,
|
|
||||||
TransactionType.BUY_PRODUCT,
|
|
||||||
TransactionType.TRANSFER,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
and_(
|
|
||||||
Transaction.type_ == TransactionType.TRANSFER,
|
|
||||||
Transaction.transfer_user_id == user_id,
|
|
||||||
),
|
|
||||||
Transaction.type_.in_(
|
|
||||||
[
|
|
||||||
TransactionType.ADJUST_INTEREST,
|
|
||||||
TransactionType.ADJUST_PENALTY,
|
|
||||||
]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
case(
|
|
||||||
(literal(until_including), Transaction.time <= until),
|
|
||||||
else_=Transaction.time < until,
|
|
||||||
)
|
|
||||||
if until is not None
|
|
||||||
else literal(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,
|
|
||||||
recursive_cte.c.balance + trx_subset.c.amount,
|
|
||||||
),
|
|
||||||
# Adds a product -> balance increases
|
|
||||||
(
|
|
||||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT,
|
|
||||||
recursive_cte.c.balance + trx_subset.c.amount,
|
|
||||||
),
|
|
||||||
# Buys a product -> balance decreases
|
|
||||||
(
|
|
||||||
trx_subset.c.type_ == TransactionType.BUY_PRODUCT,
|
|
||||||
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(1)
|
|
||||||
).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) / 100)
|
|
||||||
# Penalty
|
|
||||||
* case(
|
|
||||||
(
|
|
||||||
# TODO: should this be <= or <?
|
|
||||||
recursive_cte.c.balance < recursive_cte.c.penalty_threshold,
|
|
||||||
(
|
|
||||||
cast(recursive_cte.c.penalty_multiplier_percent, Float)
|
|
||||||
/ 100
|
|
||||||
),
|
|
||||||
),
|
|
||||||
else_=1.0,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
Integer,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
# Transfers money to self -> balance increases
|
|
||||||
(
|
|
||||||
and_(
|
|
||||||
trx_subset.c.type_ == TransactionType.TRANSFER,
|
|
||||||
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,
|
|
||||||
trx_subset.c.transfer_user_id != user_id,
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
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,
|
|
||||||
trx_subset.c.penalty_threshold,
|
|
||||||
),
|
|
||||||
else_=recursive_cte.c.penalty_threshold,
|
|
||||||
).label("penalty_threshold"),
|
|
||||||
case(
|
|
||||||
(
|
|
||||||
trx_subset.c.type_ == TransactionType.ADJUST_PENALTY,
|
|
||||||
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 + 1)
|
|
||||||
)
|
|
||||||
|
|
||||||
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?
|
|
||||||
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def user_balance(
|
|
||||||
sql_session: Session,
|
|
||||||
user: User,
|
|
||||||
use_cache: bool = True,
|
|
||||||
until: Transaction | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""
|
|
||||||
Calculates the balance of a user.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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(1)
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Transaction, User
|
|
||||||
|
|
||||||
# TODO: allow filtering out 'special transactions' like 'ADJUST_INTEREST' and 'ADJUST_PENALTY'
|
|
||||||
|
|
||||||
|
|
||||||
def user_transactions(sql_session: Session, user: User) -> list[Transaction]:
|
|
||||||
"""
|
|
||||||
Returns the transactions of the user in chronological order.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return list(
|
|
||||||
sql_session.scalars(
|
|
||||||
select(Transaction)
|
|
||||||
.where(Transaction.user_id == user.id)
|
|
||||||
.order_by(Transaction.time.asc())
|
|
||||||
).all()
|
|
||||||
)
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from dibbler.db import Session
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
|
|
||||||
JSON_FILE = Path(__file__).parent.parent.parent / "mock_data.json"
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: integrate this as a part of create-db, either asking interactively
|
|
||||||
# whether to seed test data, or by using command line arguments for
|
|
||||||
# automatating the answer.
|
|
||||||
|
|
||||||
|
|
||||||
def clear_db(sql_session):
|
|
||||||
sql_session.query(Product).delete()
|
|
||||||
sql_session.query(User).delete()
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# TODO: There is some leftover json data in the mock_data.json file.
|
|
||||||
# It should be dealt with before merging this PR, either by removing
|
|
||||||
# it or using it here.
|
|
||||||
sql_session = Session()
|
|
||||||
clear_db(sql_session)
|
|
||||||
|
|
||||||
# Add users
|
|
||||||
user1 = User("Test User 1")
|
|
||||||
user2 = User("Test User 2")
|
|
||||||
|
|
||||||
sql_session.add(user1)
|
|
||||||
sql_session.add(user2)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
# Add products
|
|
||||||
product1 = Product("1234567890123", "Test Product 1")
|
|
||||||
product2 = Product("9876543210987", "Test Product 2")
|
|
||||||
sql_session.add(product1)
|
|
||||||
sql_session.add(product2)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
# Add transactions
|
|
||||||
transactions = [
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
amount=100,
|
|
||||||
user_id=user1.id,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 1),
|
|
||||||
amount=50,
|
|
||||||
user_id=user2.id,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 2),
|
|
||||||
amount=-50,
|
|
||||||
user_id=user1.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
amount=27 * 2,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product1.id,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
product_count=1,
|
|
||||||
user_id=user2.id,
|
|
||||||
product_id=product1.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
[general]
|
[general]
|
||||||
quit_allowed = true
|
; quit_allowed = false
|
||||||
stop_allowed = false
|
; stop_allowed = false
|
||||||
|
quit_allowed = true ; not for prod
|
||||||
|
stop_allowed = true ; not for prod
|
||||||
show_tracebacks = true
|
show_tracebacks = true
|
||||||
input_encoding = 'utf8'
|
input_encoding = 'utf8'
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
# url = "postgresql://robertem@127.0.0.1/pvvvv"
|
; url = postgresql://dibbler:hunter2@127.0.0.1/pvvvv
|
||||||
url = sqlite:///test.db
|
url = sqlite:///test.db ; devenv will override this to postgres using DIBBLER_DATABASE_URL
|
||||||
|
|
||||||
[limits]
|
[limits]
|
||||||
low_credit_warning_limit = -100
|
low_credit_warning_limit = -100
|
||||||
user_recent_transaction_limit = 100
|
user_recent_transaction_limit = 100
|
||||||
|
|
||||||
# See https://pypi.org/project/brother_ql/ for label types
|
# See https://pypi.org/project/brother_ql_next/ for label types
|
||||||
# Set rotate to False for endless labels
|
# Set rotate to False for endless labels
|
||||||
[printer]
|
[printer]
|
||||||
label_type = "62"
|
label_type = "62"
|
||||||
|
|||||||
249
flake.lock
generated
249
flake.lock
generated
@@ -1,5 +1,93 @@
|
|||||||
{
|
{
|
||||||
"nodes": {
|
"nodes": {
|
||||||
|
"cachix": {
|
||||||
|
"inputs": {
|
||||||
|
"devenv": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"git-hooks": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1728672398,
|
||||||
|
"narHash": "sha256-KxuGSoVUFnQLB2ZcYODW7AVPAh9JqRlD5BrfsC/Q4qs=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "cachix",
|
||||||
|
"rev": "aac51f698309fd0f381149214b7eee213c66ef0a",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "latest",
|
||||||
|
"repo": "cachix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"devenv": {
|
||||||
|
"inputs": {
|
||||||
|
"cachix": "cachix",
|
||||||
|
"flake-compat": "flake-compat",
|
||||||
|
"git-hooks": "git-hooks",
|
||||||
|
"nix": "nix",
|
||||||
|
"nixpkgs": "nixpkgs_3"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731619804,
|
||||||
|
"narHash": "sha256-wyxFaVooL8SzvQNpolpx32X+GoBPnCAg9E0i/Ekn3FU=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"rev": "87edaaf1dddf17fe16eabab3c8edaf7cca2c3bc2",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-compat": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1696426674,
|
||||||
|
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "edolstra",
|
||||||
|
"repo": "flake-compat",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-parts": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs-lib": [
|
||||||
|
"devenv",
|
||||||
|
"nix",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1712014858,
|
||||||
|
"narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"rev": "9126214d0a59633752a136528f5f3b9aa8565b7d",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "flake-parts",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"flake-utils": {
|
"flake-utils": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"systems": "systems"
|
"systems": "systems"
|
||||||
@@ -13,17 +101,117 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
"id": "flake-utils",
|
"owner": "numtide",
|
||||||
"type": "indirect"
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"git-hooks": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"gitignore": "gitignore",
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"nixpkgs"
|
||||||
|
],
|
||||||
|
"nixpkgs-stable": [
|
||||||
|
"devenv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1730302582,
|
||||||
|
"narHash": "sha256-W1MIJpADXQCgosJZT8qBYLRuZls2KSiKdpnTVdKBuvU=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"rev": "af8a16fe5c264f5e9e18bcee2859b40a656876cf",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "git-hooks.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"gitignore": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"devenv",
|
||||||
|
"git-hooks",
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1709087332,
|
||||||
|
"narHash": "sha256-HG2cCnktfHsKV0s4XW83gU3F57gaTljL9KNSuG6bnQs=",
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"rev": "637db329424fd7e46cf4185293b9cc8c88c95394",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "hercules-ci",
|
||||||
|
"repo": "gitignore.nix",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"libgit2": {
|
||||||
|
"flake": false,
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1697646580,
|
||||||
|
"narHash": "sha256-oX4Z3S9WtJlwvj0uH9HlYcWv+x1hqp8mhXl7HsLu2f0=",
|
||||||
|
"owner": "libgit2",
|
||||||
|
"repo": "libgit2",
|
||||||
|
"rev": "45fd9ed7ae1a9b74b957ef4f337bc3c8b3df01b5",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "libgit2",
|
||||||
|
"repo": "libgit2",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nix": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-compat": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"flake-parts": "flake-parts",
|
||||||
|
"libgit2": "libgit2",
|
||||||
|
"nixpkgs": "nixpkgs_2",
|
||||||
|
"nixpkgs-23-11": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"nixpkgs-regression": [
|
||||||
|
"devenv"
|
||||||
|
],
|
||||||
|
"pre-commit-hooks": [
|
||||||
|
"devenv"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1727438425,
|
||||||
|
"narHash": "sha256-X8ES7I1cfNhR9oKp06F6ir4Np70WGZU5sfCOuNBEwMg=",
|
||||||
|
"owner": "domenkozar",
|
||||||
|
"repo": "nix",
|
||||||
|
"rev": "f6c5ae4c1b2e411e6b1e6a8181cc84363d6a7546",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "domenkozar",
|
||||||
|
"ref": "devenv-2.24",
|
||||||
|
"repo": "nix",
|
||||||
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1749285348,
|
"lastModified": 1730531603,
|
||||||
"narHash": "sha256-frdhQvPbmDYaScPFiCnfdh3B/Vh81Uuoo0w5TkWmmjU=",
|
"narHash": "sha256-Dqg6si5CqIzm87sp57j5nTaeBbWhHFaVyG7V6L8k3lY=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "3e3afe5174c561dee0df6f2c2b2236990146329f",
|
"rev": "7ffd9ae656aec493492b44d0ddfb28e79a1ea25d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
@@ -33,10 +221,59 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"nixpkgs_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1717432640,
|
||||||
|
"narHash": "sha256-+f9c4/ZX5MWDOuB1rKoWj+lBNm0z0rs4CK47HBLxy1o=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "88269ab3044128b7c2f4c7d68448b2fb50456870",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "release-24.05",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_3": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1716977621,
|
||||||
|
"narHash": "sha256-Q1UQzYcMJH4RscmpTkjlgqQDX5yi1tZL0O345Ri6vXQ=",
|
||||||
|
"owner": "cachix",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"rev": "4267e705586473d3e5c8d50299e71503f16a6fb6",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "cachix",
|
||||||
|
"ref": "rolling",
|
||||||
|
"repo": "devenv-nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs_4": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731611831,
|
||||||
|
"narHash": "sha256-R51rOqkWMfubBkZ9BY4Y1VaRoeqEBshlfQ8mMH5RjqI=",
|
||||||
|
"owner": "nixos",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "cea28c811faadb50bee00d433bbf2fea845a43e4",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nixos",
|
||||||
|
"ref": "nixos-unstable-small",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
|
"devenv": "devenv",
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs_4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"systems": {
|
"systems": {
|
||||||
|
|||||||
151
flake.nix
151
flake.nix
@@ -1,65 +1,128 @@
|
|||||||
{
|
{
|
||||||
description = "Dibbler samspleisebod";
|
description = "Dibbler samspleisebod";
|
||||||
|
|
||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs = {
|
||||||
|
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable-small";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
devenv.url = "github:cachix/devenv";
|
||||||
|
};
|
||||||
|
|
||||||
outputs = { self, nixpkgs, flake-utils }: let
|
nixConfig = {
|
||||||
inherit (nixpkgs) lib;
|
extra-trusted-public-keys = [
|
||||||
|
"devenv.cachix.org-1:w1cLUi8dv3hnoSPGAuibQv+f9TZLr6cv/Hm9XgU50cw="
|
||||||
|
];
|
||||||
|
extra-substituters = [
|
||||||
|
"https://devenv.cachix.org"
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
systems = [
|
outputs = { self, ... } @ inputs:
|
||||||
"x86_64-linux"
|
inputs.flake-utils.lib.eachDefaultSystem (system: let
|
||||||
"aarch64-linux"
|
pkgs = inputs.nixpkgs.legacyPackages.${system};
|
||||||
"x86_64-darwin"
|
inherit (pkgs) lib;
|
||||||
"aarch64-darwin"
|
in {
|
||||||
|
|
||||||
|
packages = {
|
||||||
|
default = self.packages.${system}.dibbler;
|
||||||
|
|
||||||
|
dibbler = pkgs.python311Packages.callPackage ./nix/dibbler.nix { };
|
||||||
|
skrot-vm = self.nixosConfigurations.skrot.config.system.build.vm;
|
||||||
|
|
||||||
|
# devenv cruft
|
||||||
|
devenv-up = self.devShells.${system}.default.config.procfileScript;
|
||||||
|
devenv-test = self.devShells.${system}.default.config.test;
|
||||||
|
};
|
||||||
|
|
||||||
|
devShells = {
|
||||||
|
default = self.devShells.${system}.dibbler;
|
||||||
|
dibbler = inputs.devenv.lib.mkShell {
|
||||||
|
inherit inputs pkgs;
|
||||||
|
modules = [({ config, ... }: {
|
||||||
|
# https://devenv.sh/reference/options/
|
||||||
|
|
||||||
|
enterShell = ''
|
||||||
|
if [[ ! -f config.ini ]]; then
|
||||||
|
cp -v example-config.ini config.ini
|
||||||
|
fi
|
||||||
|
|
||||||
|
export REPO_ROOT=$(realpath .) # used by mkPythonEditablePackage
|
||||||
|
export DIBBLER_CONFIG_FILE=$(realpath config.ini)
|
||||||
|
export DIBBLER_DATABASE_URL=postgresql://dibbler:hunter2@/dibbler?host=${config.env.PGHOST}
|
||||||
|
'';
|
||||||
|
|
||||||
|
packages = [
|
||||||
|
|
||||||
|
/* self.packages.${system}.dibbler */
|
||||||
|
(pkgs.python311Packages.mkPythonEditablePackage {
|
||||||
|
inherit (self.packages.${system}.dibbler)
|
||||||
|
pname version
|
||||||
|
build-system dependencies;
|
||||||
|
scripts = (lib.importTOML ./pyproject.toml).project.scripts;
|
||||||
|
root = "$REPO_ROOT";
|
||||||
|
})
|
||||||
|
|
||||||
|
pkgs.python311Packages.black
|
||||||
|
pkgs.ruff
|
||||||
];
|
];
|
||||||
|
|
||||||
forAllSystems = f: lib.genAttrs systems (system: let
|
services.postgres = {
|
||||||
pkgs = nixpkgs.legacyPackages.${system};
|
enable = true;
|
||||||
in f system pkgs);
|
initialDatabases = [
|
||||||
in {
|
{
|
||||||
packages = forAllSystems (system: pkgs: {
|
name = "dibbler";
|
||||||
default = self.packages.${system}.dibbler;
|
user = "dibbler";
|
||||||
dibbler = pkgs.callPackage ./nix/dibbler.nix {
|
pass = "hunter2";
|
||||||
python3Packages = pkgs.python312Packages;
|
}
|
||||||
|
];
|
||||||
};
|
};
|
||||||
skrot = self.nixosConfigurations.skrot.config.system.build.sdImage;
|
|
||||||
});
|
|
||||||
|
|
||||||
apps = forAllSystems (system: pkgs: {
|
})];
|
||||||
default = self.apps.${system}.dibbler;
|
|
||||||
dibbler = flake-utils.lib.mkApp {
|
|
||||||
drv = self.packages.${system}.dibbler;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
overlays = {
|
|
||||||
default = self.overlays.dibbler;
|
|
||||||
dibbler = final: prev: {
|
|
||||||
inherit (self.packages.${prev.system}) dibbler;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells = forAllSystems (system: pkgs: {
|
})
|
||||||
default = self.devShells.${system}.dibbler;
|
|
||||||
dibbler = pkgs.callPackage ./nix/shell.nix {
|
|
||||||
python = pkgs.python312;
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
# Note: using the module requires that you have applied the overlay first
|
//
|
||||||
|
|
||||||
|
{
|
||||||
|
# Note: using the module requires that you have applied the
|
||||||
|
# overlay first
|
||||||
nixosModules.default = import ./nix/module.nix;
|
nixosModules.default = import ./nix/module.nix;
|
||||||
|
|
||||||
nixosConfigurations.skrot = nixpkgs.lib.nixosSystem (rec {
|
images.skrot = self.nixosConfigurations.skrot.config.system.build.sdImage;
|
||||||
|
|
||||||
|
nixosConfigurations.skrot = inputs.nixpkgs.lib.nixosSystem {
|
||||||
system = "aarch64-linux";
|
system = "aarch64-linux";
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
overlays = [ self.overlays.dibbler ];
|
|
||||||
};
|
|
||||||
modules = [
|
modules = [
|
||||||
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
(inputs.nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
||||||
self.nixosModules.default
|
self.nixosModules.default
|
||||||
./nix/skrott.nix
|
({...}: {
|
||||||
|
system.stateVersion = "22.05";
|
||||||
|
|
||||||
|
networking = {
|
||||||
|
hostName = "skrot";
|
||||||
|
domain = "pvv.ntnu.no";
|
||||||
|
nameservers = [ "129.241.0.200" "129.241.0.201" ];
|
||||||
|
defaultGateway = "129.241.210.129";
|
||||||
|
interfaces.eth0 = {
|
||||||
|
useDHCP = false;
|
||||||
|
ipv4.addresses = [{
|
||||||
|
address = "129.241.210.235";
|
||||||
|
prefixLength = 25;
|
||||||
|
}];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
# services.resolved.enable = true;
|
||||||
|
# systemd.network.enable = true;
|
||||||
|
# systemd.network.networks."30-network" = {
|
||||||
|
# matchConfig.Name = "*";
|
||||||
|
# DHCP = "no";
|
||||||
|
# address = [ "129.241.210.235/25" ];
|
||||||
|
# gateway = [ "129.241.210.129" ];
|
||||||
|
# };
|
||||||
|
})
|
||||||
];
|
];
|
||||||
});
|
};
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
{
|
|
||||||
"products": [
|
|
||||||
{
|
|
||||||
"product_id": 1,
|
|
||||||
"bar_code": "1234567890123",
|
|
||||||
"name": "Wireless Mouse",
|
|
||||||
"price": 2999,
|
|
||||||
"stock": 150,
|
|
||||||
"hidden": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"product_id": 2,
|
|
||||||
"bar_code": "9876543210987",
|
|
||||||
"name": "Mechanical Keyboard",
|
|
||||||
"price": 5999,
|
|
||||||
"stock": 75,
|
|
||||||
"hidden": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"product_id": 3,
|
|
||||||
"bar_code": "1112223334445",
|
|
||||||
"name": "Gaming Monitor",
|
|
||||||
"price": 19999,
|
|
||||||
"stock": 20,
|
|
||||||
"hidden": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"product_id": 4,
|
|
||||||
"bar_code": "5556667778889",
|
|
||||||
"name": "USB-C Docking Station",
|
|
||||||
"price": 8999,
|
|
||||||
"stock": 50,
|
|
||||||
"hidden": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"product_id": 5,
|
|
||||||
"bar_code": "4445556667771",
|
|
||||||
"name": "Noise Cancelling Headphones",
|
|
||||||
"price": 12999,
|
|
||||||
"stock": 30,
|
|
||||||
"hidden": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"name": "Albert",
|
|
||||||
"credit": 42069,
|
|
||||||
"card": "NTU12345678",
|
|
||||||
"rfid": "a1b2c3d4e5"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "lorem",
|
|
||||||
"credit": 2000,
|
|
||||||
"card": "9876543210",
|
|
||||||
"rfid": "f6e7d8c9b0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ibsum",
|
|
||||||
"credit": 1000,
|
|
||||||
"card": "11122233",
|
|
||||||
"rfid": ""
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dave",
|
|
||||||
"credit": 7500,
|
|
||||||
"card": "NTU56789012",
|
|
||||||
"rfid": "1234abcd5678"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "eve",
|
|
||||||
"credit": 3000,
|
|
||||||
"card": null,
|
|
||||||
"rfid": "deadbeef1234"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,25 @@
|
|||||||
{ lib
|
{ lib
|
||||||
, python3Packages
|
|
||||||
, fetchFromGitHub
|
, fetchFromGitHub
|
||||||
|
, buildPythonApplication
|
||||||
|
, setuptools
|
||||||
|
, brother-ql
|
||||||
|
, matplotlib
|
||||||
|
, psycopg2
|
||||||
|
, python-barcode
|
||||||
|
, sqlalchemy
|
||||||
}:
|
}:
|
||||||
python3Packages.buildPythonApplication {
|
|
||||||
|
buildPythonApplication {
|
||||||
pname = "dibbler";
|
pname = "dibbler";
|
||||||
version = "unstable";
|
version = "0.0.0";
|
||||||
|
pyproject = true;
|
||||||
|
|
||||||
src = lib.cleanSource ../.;
|
src = lib.cleanSource ../.;
|
||||||
|
|
||||||
format = "pyproject";
|
build-system = [ setuptools ];
|
||||||
|
dependencies = [
|
||||||
# brother-ql is breaky breaky
|
# we override pname to satisfy mkPythonEditablePackage
|
||||||
# https://github.com/NixOS/nixpkgs/issues/285234
|
(brother-ql.overridePythonAttrs { pname = "brother-ql-next"; })
|
||||||
dontCheckRuntimeDeps = true;
|
|
||||||
|
|
||||||
pythonImportsCheck = [];
|
|
||||||
|
|
||||||
doCheck = true;
|
|
||||||
nativeCheckInputs = with python3Packages; [
|
|
||||||
pytest
|
|
||||||
pytestCheckHook
|
|
||||||
];
|
|
||||||
|
|
||||||
nativeBuildInputs = with python3Packages; [ setuptools ];
|
|
||||||
propagatedBuildInputs = with python3Packages; [
|
|
||||||
brother-ql
|
|
||||||
matplotlib
|
matplotlib
|
||||||
psycopg2
|
psycopg2
|
||||||
python-barcode
|
python-barcode
|
||||||
|
|||||||
@@ -1,31 +1,16 @@
|
|||||||
{ config, pkgs, lib, ... }: let
|
{ config, pkgs, lib, ... }: let
|
||||||
cfg = config.services.dibbler;
|
cfg = config.services.dibbler;
|
||||||
|
|
||||||
format = pkgs.formats.ini { };
|
|
||||||
in {
|
in {
|
||||||
options.services.dibbler = {
|
options.services.dibbler = {
|
||||||
enable = lib.mkEnableOption "dibbler, the little kiosk computer";
|
|
||||||
|
|
||||||
package = lib.mkPackageOption pkgs "dibbler" { };
|
package = lib.mkPackageOption pkgs "dibbler" { };
|
||||||
|
config = lib.mkOption {
|
||||||
settings = lib.mkOption {
|
default = ../conf.py;
|
||||||
description = "Configuration for dibbler";
|
|
||||||
default = { };
|
|
||||||
type = lib.types.submodule {
|
|
||||||
freeformType = format.type;
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = let
|
config = let
|
||||||
screen = "${pkgs.screen}/bin/screen";
|
screen = "${pkgs.screen}/bin/screen";
|
||||||
in lib.mkIf cfg.enable {
|
in {
|
||||||
services.dibbler.settings = lib.pipe ../example-config.ini [
|
|
||||||
builtins.readFile
|
|
||||||
builtins.fromTOML
|
|
||||||
(lib.mapAttrsRecursive (_: lib.mkDefault))
|
|
||||||
];
|
|
||||||
|
|
||||||
boot = {
|
boot = {
|
||||||
consoleLogLevel = 0;
|
consoleLogLevel = 0;
|
||||||
enableContainers = false;
|
enableContainers = false;
|
||||||
@@ -38,7 +23,10 @@ in {
|
|||||||
group = "dibbler";
|
group = "dibbler";
|
||||||
extraGroups = [ "lp" ];
|
extraGroups = [ "lp" ];
|
||||||
isNormalUser = true;
|
isNormalUser = true;
|
||||||
shell = (pkgs.writeShellScriptBin "login-shell" "${screen} -x dibbler") // {shellPath = "/bin/login-shell";};
|
shell = (
|
||||||
|
(pkgs.writeShellScriptBin "login-shell" "${screen} -x dibbler")
|
||||||
|
// {shellPath = "/bin/login-shell";}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,9 +35,7 @@ in {
|
|||||||
wantedBy = [ "default.target" ];
|
wantedBy = [ "default.target" ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
ExecStartPre = "-${screen} -X -S dibbler kill";
|
ExecStartPre = "-${screen} -X -S dibbler kill";
|
||||||
ExecStart = let
|
ExecStart = "${screen} -dmS dibbler -O -l ${cfg.package}/bin/dibbler --config ${cfg.config} loop";
|
||||||
config = format.generate "dibbler-config.ini" cfg.settings;
|
|
||||||
in "${screen} -dmS dibbler -O -l ${cfg.package}/bin/dibbler --config ${config} loop";
|
|
||||||
ExecStartPost = "${screen} -X -S dibbler width 42 80";
|
ExecStartPost = "${screen} -X -S dibbler width 42 80";
|
||||||
User = "dibbler";
|
User = "dibbler";
|
||||||
Group = "dibbler";
|
Group = "dibbler";
|
||||||
@@ -86,7 +72,7 @@ in {
|
|||||||
console.keyMap = "no";
|
console.keyMap = "no";
|
||||||
programs.command-not-found.enable = false;
|
programs.command-not-found.enable = false;
|
||||||
i18n.supportedLocales = [ "en_US.UTF-8/UTF-8" ];
|
i18n.supportedLocales = [ "en_US.UTF-8/UTF-8" ];
|
||||||
# environment.noXlibs = true;
|
environment.noXlibs = true;
|
||||||
|
|
||||||
documentation = {
|
documentation = {
|
||||||
info.enable = false;
|
info.enable = false;
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
mkShell,
|
|
||||||
python,
|
|
||||||
ruff,
|
|
||||||
uv,
|
|
||||||
}:
|
|
||||||
|
|
||||||
mkShell {
|
|
||||||
packages = [
|
|
||||||
ruff
|
|
||||||
uv
|
|
||||||
(python.withPackages (ps: with ps; [
|
|
||||||
brother-ql
|
|
||||||
matplotlib
|
|
||||||
psycopg2
|
|
||||||
python-barcode
|
|
||||||
sqlalchemy
|
|
||||||
|
|
||||||
pytest
|
|
||||||
pytest-cov
|
|
||||||
]))
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{...}: {
|
|
||||||
system.stateVersion = "25.05";
|
|
||||||
|
|
||||||
services.dibbler.enable = true;
|
|
||||||
|
|
||||||
networking = {
|
|
||||||
hostName = "skrot";
|
|
||||||
domain = "pvv.ntnu.no";
|
|
||||||
nameservers = [ "129.241.0.200" "129.241.0.201" ];
|
|
||||||
defaultGateway = "129.241.210.129";
|
|
||||||
interfaces.eth0 = {
|
|
||||||
useDHCP = false;
|
|
||||||
ipv4.addresses = [{
|
|
||||||
address = "129.241.210.235";
|
|
||||||
prefixLength = 25;
|
|
||||||
}];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
# services.resolved.enable = true;
|
|
||||||
# systemd.network.enable = true;
|
|
||||||
# systemd.network.networks."30-network" = {
|
|
||||||
# matchConfig.Name = "*";
|
|
||||||
# DHCP = "no";
|
|
||||||
# address = [ "129.241.210.235/25" ];
|
|
||||||
# gateway = [ "129.241.210.129" ];
|
|
||||||
# };
|
|
||||||
}
|
|
||||||
@@ -8,24 +8,19 @@ authors = []
|
|||||||
description = "EDB-system for PVV"
|
description = "EDB-system for PVV"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
license = {text = "BSD-3-Clause"}
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
]
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"SQLAlchemy >= 2.0, <2.1",
|
"SQLAlchemy >= 2.0, <2.1",
|
||||||
"brother-ql",
|
"brother_ql_next",
|
||||||
"matplotlib",
|
"matplotlib",
|
||||||
"psycopg2 >= 2.8, <2.10",
|
"psycopg2 >= 2.8, <2.10",
|
||||||
"python-barcode",
|
"python-barcode",
|
||||||
]
|
]
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
dev = [
|
|
||||||
"pytest",
|
|
||||||
"pytest-cov",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["dibbler*"]
|
include = ["dibbler*"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import pytest
|
|
||||||
|
|
||||||
from sqlalchemy import create_engine, event
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Base
|
|
||||||
|
|
||||||
|
|
||||||
def pytest_addoption(parser):
|
|
||||||
parser.addoption(
|
|
||||||
"--echo",
|
|
||||||
action="store_true",
|
|
||||||
help="Enable SQLAlchemy echo mode for debugging",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="function")
|
|
||||||
def sql_session(request):
|
|
||||||
"""Create a new SQLAlchemy session for testing."""
|
|
||||||
|
|
||||||
echo = request.config.getoption("--echo")
|
|
||||||
|
|
||||||
engine = create_engine(
|
|
||||||
"sqlite:///:memory:",
|
|
||||||
echo=echo,
|
|
||||||
)
|
|
||||||
|
|
||||||
@event.listens_for(engine, "connect")
|
|
||||||
def set_sqlite_pragma(dbapi_connection, _connection_record):
|
|
||||||
cursor = dbapi_connection.cursor()
|
|
||||||
cursor.execute("PRAGMA foreign_keys=ON")
|
|
||||||
cursor.close()
|
|
||||||
|
|
||||||
Base.metadata.create_all(engine)
|
|
||||||
with Session(engine) as sql_session:
|
|
||||||
yield sql_session
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import pytest
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> Product:
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
return product
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_no_duplicate_barcodes(sql_session: Session):
|
|
||||||
product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
duplicate_product = Product(product.bar_code, "Hehe >:)")
|
|
||||||
sql_session.add(duplicate_product)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_no_duplicate_names(sql_session: Session):
|
|
||||||
product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
duplicate_product = Product("1918238911928", product.name)
|
|
||||||
sql_session.add(duplicate_product)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
from dibbler.queries.product_stock import product_stock
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> tuple[User, Product]:
|
|
||||||
user = User("Test User")
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
|
|
||||||
sql_session.add(user)
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
return user, product
|
|
||||||
|
|
||||||
|
|
||||||
def test_transaction_no_duplicate_timestamps(sql_session: Session):
|
|
||||||
user, _ = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transaction1 = Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
amount=100,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction1)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
transaction2 = Transaction.adjust_balance(
|
|
||||||
time=transaction1.time,
|
|
||||||
user_id=user.id,
|
|
||||||
amount=-50,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction2)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_not_allowed_to_transfer_to_self(sql_session: Session) -> None:
|
|
||||||
user, _ = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transaction = Transaction.transfer(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
transfer_user_id=user.id,
|
|
||||||
amount=50,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_foreign_key_constraint(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transaction = Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
# Attempt to add a transaction with a non-existent product
|
|
||||||
invalid_transaction = Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=9999, # Non-existent product ID
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(invalid_transaction)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_foreign_key_constraint(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transaction = Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
# Attempt to add a transaction with a non-existent user
|
|
||||||
invalid_transaction = Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
user_id=9999, # Non-existent user ID
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(invalid_transaction)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_transaction_buy_product_more_than_stock(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
product_count=10,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
assert product_stock(sql_session, product) == 1 - 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_transaction_buy_product_dont_allow_no_add_product_transactions(
|
|
||||||
sql_session: Session,
|
|
||||||
) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transaction = Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
product_count=1,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_transaction_add_product_deny_amount_over_per_product_times_product_count(
|
|
||||||
sql_session: Session,
|
|
||||||
) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError):
|
|
||||||
_transaction = Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27 * 2 + 1, # Invalid amount
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_transaction_add_product_allow_amount_under_per_product_times_product_count(
|
|
||||||
sql_session: Session,
|
|
||||||
) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transaction = Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27 * 2 - 1, # Valid amount
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
)
|
|
||||||
|
|
||||||
sql_session.add(transaction)
|
|
||||||
sql_session.commit()
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from sqlalchemy.exc import IntegrityError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> User:
|
|
||||||
user = User("Test User")
|
|
||||||
sql_session.add(user)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def test_ensure_no_duplicate_user_names(sql_session: Session):
|
|
||||||
user = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
user2 = User(user.name)
|
|
||||||
sql_session.add(user2)
|
|
||||||
|
|
||||||
with pytest.raises(IntegrityError):
|
|
||||||
sql_session.commit()
|
|
||||||
@@ -1,342 +0,0 @@
|
|||||||
import math
|
|
||||||
from datetime import datetime
|
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
from dibbler.queries.product_price import product_price, product_price_log
|
|
||||||
|
|
||||||
# TODO: see if we can use pytest_runtest_makereport to print the "product_price_log"s
|
|
||||||
# only on failures instead of inlining it in every test function
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> tuple[User, Product]:
|
|
||||||
user = User("Test User")
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
|
|
||||||
sql_session.add(user)
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
return user, product
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_no_transactions(sql_session: Session) -> None:
|
|
||||||
_, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
assert product_price(sql_session, product) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_basic_history(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
amount=27 * 2 - 1,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
assert product_price(sql_session, product) == 27
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_sold_out(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
amount=27 * 2 - 1,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
assert product_price(sql_session, product) == 27
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_interest(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
interest_rate_percent=110,
|
|
||||||
user_id=user.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
amount=27 * 2 - 1,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
product_price_ = product_price(sql_session, product)
|
|
||||||
product_price_interest = product_price(sql_session, product, include_interest=True)
|
|
||||||
|
|
||||||
assert product_price_ == 27
|
|
||||||
assert product_price_interest == math.ceil(27 * 1.1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_changing_interest(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
interest_rate_percent=110,
|
|
||||||
user_id=user.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
amount=27 * 2 - 1,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
|
||||||
interest_rate_percent=120,
|
|
||||||
user_id=user.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
product_price_interest = product_price(sql_session, product, include_interest=True)
|
|
||||||
assert product_price_interest == math.ceil(27 * 1.2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_old_transaction(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
amount=27 * 2,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
# Price should be 27
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
|
||||||
amount=38 * 3,
|
|
||||||
per_product=38,
|
|
||||||
product_count=3,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
# price should be averaged upwards
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
until_transaction = transactions[0]
|
|
||||||
|
|
||||||
pprint(
|
|
||||||
product_price_log(
|
|
||||||
sql_session,
|
|
||||||
product,
|
|
||||||
until=until_transaction,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
product_price_ = product_price(
|
|
||||||
sql_session,
|
|
||||||
product,
|
|
||||||
until=until_transaction,
|
|
||||||
)
|
|
||||||
assert product_price_ == 27
|
|
||||||
|
|
||||||
|
|
||||||
# Price goes up and gets rounded up to the next integer
|
|
||||||
def test_product_price_round_up_from_below(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
amount=27 * 2,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
# Price should be 27
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
|
||||||
amount=38 * 3,
|
|
||||||
per_product=38,
|
|
||||||
product_count=3,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
# price should be averaged upwards
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
product_price_ = product_price(sql_session, product)
|
|
||||||
assert product_price_ == math.ceil((27 * 2 + 38 * 3) / (2 + 3))
|
|
||||||
|
|
||||||
|
|
||||||
# Price goes down and gets rounded up to the next integer
|
|
||||||
def test_product_price_round_up_from_above(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
amount=27 * 2,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
# Price should be 27
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
|
||||||
amount=20 * 3,
|
|
||||||
per_product=20,
|
|
||||||
product_count=3,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
# price should be averaged downwards
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
product_price_ = product_price(sql_session, product)
|
|
||||||
assert product_price_ == math.ceil((27 * 2 + 20 * 3) / (2 + 3))
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_price_with_negative_stock_single_addition(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
amount=1,
|
|
||||||
per_product=10,
|
|
||||||
product_count=1,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 1),
|
|
||||||
product_count=10,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 2),
|
|
||||||
amount=22,
|
|
||||||
per_product=22,
|
|
||||||
product_count=1,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
# Stock went subzero, price should be the last added product price
|
|
||||||
product1_price = product_price(sql_session, product)
|
|
||||||
assert product1_price == 22
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: what happens when stock is still negative and yet new products are added?
|
|
||||||
def test_product_price_with_negative_stock_multiple_additions(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
amount=1,
|
|
||||||
per_product=10,
|
|
||||||
product_count=1,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 1),
|
|
||||||
product_count=10,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 2),
|
|
||||||
amount=22,
|
|
||||||
per_product=22,
|
|
||||||
product_count=1,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 3),
|
|
||||||
amount=29,
|
|
||||||
per_product=29,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(product_price_log(sql_session, product))
|
|
||||||
|
|
||||||
# Stock went subzero, price should be the ceiled average of the last added products
|
|
||||||
product1_price = product_price(sql_session, product)
|
|
||||||
assert product1_price == math.ceil((22 + 29 * 2) / (1 + 2))
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
from dibbler.queries.product_stock import product_stock
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> None:
|
|
||||||
user1 = User("Test User 1")
|
|
||||||
|
|
||||||
sql_session.add(user1)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_stock_basic_history(sql_session: Session) -> None:
|
|
||||||
insert_test_data(sql_session)
|
|
||||||
|
|
||||||
user1 = sql_session.scalars(select(User).where(User.name == "Test User 1")).one()
|
|
||||||
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
amount=10,
|
|
||||||
per_product=10,
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
assert product_stock(sql_session, product) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_stock_complex_history(sql_session: Session) -> None:
|
|
||||||
insert_test_data(sql_session)
|
|
||||||
|
|
||||||
user1 = sql_session.scalars(select(User).where(User.name == "Test User 1")).one()
|
|
||||||
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
amount=27 * 2,
|
|
||||||
per_product=27,
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=2,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 1),
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=3,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 2),
|
|
||||||
amount=50 * 4,
|
|
||||||
per_product=50,
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=4,
|
|
||||||
),
|
|
||||||
Transaction.adjust_stock(
|
|
||||||
time=datetime(2023, 10, 1, 15, 0, 0),
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=3,
|
|
||||||
),
|
|
||||||
Transaction.adjust_stock(
|
|
||||||
time=datetime(2023, 10, 1, 15, 0, 1),
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=-2,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
assert product_stock(sql_session, product) == 2 - 3 + 4 + 3 - 2
|
|
||||||
|
|
||||||
|
|
||||||
def test_product_stock_no_transactions(sql_session: Session) -> None:
|
|
||||||
insert_test_data(sql_session)
|
|
||||||
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
assert product_stock(sql_session, product) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_negative_product_stock(sql_session: Session) -> None:
|
|
||||||
insert_test_data(sql_session)
|
|
||||||
|
|
||||||
user1 = sql_session.scalars(select(User).where(User.name == "Test User 1")).one()
|
|
||||||
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 14, 0, 0),
|
|
||||||
amount=50,
|
|
||||||
per_product=50,
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 14, 0, 1),
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=2,
|
|
||||||
),
|
|
||||||
Transaction.adjust_stock(
|
|
||||||
time=datetime(2023, 10, 1, 16, 0, 0),
|
|
||||||
user_id=user1.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=-1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
# The stock should be negative because we added and bought the product
|
|
||||||
assert product_stock(sql_session, product) == 1 - 2 - 1
|
|
||||||
@@ -1,306 +0,0 @@
|
|||||||
import math
|
|
||||||
from datetime import datetime
|
|
||||||
from pprint import pprint
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
from dibbler.queries.user_balance import user_balance, user_balance_log
|
|
||||||
|
|
||||||
# TODO: see if we can use pytest_runtest_makereport to print the "user_balance_log"s
|
|
||||||
# only on failures instead of inlining it in every test function
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> tuple[User, Product]:
|
|
||||||
user = User("Test User")
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
|
|
||||||
sql_session.add(user)
|
|
||||||
sql_session.add(product)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
return user, product
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_no_transactions(sql_session: Session) -> None:
|
|
||||||
user, _ = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
balance = user_balance(sql_session, user)
|
|
||||||
|
|
||||||
assert balance == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_basic_history(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
amount=100,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 1),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
balance = user_balance(sql_session, user)
|
|
||||||
|
|
||||||
assert balance == 100 + 27
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_with_transfers(sql_session: Session) -> None:
|
|
||||||
user1, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
user2 = User("Test User 2")
|
|
||||||
sql_session.add(user2)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user1.id,
|
|
||||||
amount=100,
|
|
||||||
),
|
|
||||||
Transaction.transfer(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 1),
|
|
||||||
user_id=user1.id,
|
|
||||||
transfer_user_id=user2.id,
|
|
||||||
amount=50,
|
|
||||||
),
|
|
||||||
Transaction.transfer(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 2),
|
|
||||||
user_id=user2.id,
|
|
||||||
transfer_user_id=user1.id,
|
|
||||||
amount=30,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user1))
|
|
||||||
|
|
||||||
user1_balance = user_balance(sql_session, user1)
|
|
||||||
assert user1_balance == 100 - 50 + 30
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user2))
|
|
||||||
|
|
||||||
user2_balance = user_balance(sql_session, user2)
|
|
||||||
assert user2_balance == 50 - 30
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_complex_history(sql_session: Session) -> None:
|
|
||||||
raise NotImplementedError("This test is not implemented yet.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_penalty(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
amount=-200,
|
|
||||||
),
|
|
||||||
# Penalized, pays 2x the price (default penalty)
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
assert user_balance(sql_session, user) == 27 - 200 - (27 * 2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_changing_penalty(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
amount=-200,
|
|
||||||
),
|
|
||||||
# Penalized, pays 2x the price (default penalty)
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.adjust_penalty(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
penalty_multiplier_percent=300,
|
|
||||||
penalty_threshold=-100,
|
|
||||||
),
|
|
||||||
# Penalized, pays 3x the price
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 14, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
assert user_balance(sql_session, user) == 27 - 200 - (27 * 2) - (27 * 3)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_interest(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
interest_rate_percent=110,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
assert user_balance(sql_session, user) == 27 - math.ceil(27 * 1.1)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_changing_interest(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27 * 3,
|
|
||||||
per_product=27,
|
|
||||||
product_count=3,
|
|
||||||
),
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
interest_rate_percent=110,
|
|
||||||
),
|
|
||||||
# Pays 1.1x the price
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
interest_rate_percent=120,
|
|
||||||
),
|
|
||||||
# Pays 1.2x the price
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 14, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
assert user_balance(sql_session, user) == 27 * 3 - math.ceil(27 * 1.1) - math.ceil(27 * 1.2)
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_balance_penalty_interest_combined(sql_session: Session) -> None:
|
|
||||||
user, product = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
amount=27,
|
|
||||||
per_product=27,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
Transaction.adjust_interest(
|
|
||||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
interest_rate_percent=110,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
amount=-200,
|
|
||||||
),
|
|
||||||
# Penalized, pays 2x the price (default penalty)
|
|
||||||
# Pays 1.1x the price
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
product_count=1,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
pprint(user_balance_log(sql_session, user))
|
|
||||||
|
|
||||||
assert user_balance(sql_session, user) == (
|
|
||||||
27
|
|
||||||
- 200
|
|
||||||
- math.ceil(27 * 2 * 1.1)
|
|
||||||
)
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from dibbler.models import Product, Transaction, User
|
|
||||||
from dibbler.queries.user_transactions import user_transactions
|
|
||||||
|
|
||||||
|
|
||||||
def insert_test_data(sql_session: Session) -> User:
|
|
||||||
user = User("Test User")
|
|
||||||
sql_session.add(user)
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
return user
|
|
||||||
|
|
||||||
|
|
||||||
def test_user_transactions(sql_session: Session):
|
|
||||||
user = insert_test_data(sql_session)
|
|
||||||
|
|
||||||
product = Product("1234567890123", "Test Product")
|
|
||||||
user2 = User("Test User 2")
|
|
||||||
sql_session.add_all([product, user2])
|
|
||||||
sql_session.commit()
|
|
||||||
|
|
||||||
transactions = [
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
|
||||||
amount=100,
|
|
||||||
user_id=user.id,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 1),
|
|
||||||
amount=50,
|
|
||||||
user_id=user2.id,
|
|
||||||
),
|
|
||||||
Transaction.adjust_balance(
|
|
||||||
time=datetime(2023, 10, 1, 10, 0, 2),
|
|
||||||
amount=-50,
|
|
||||||
user_id=user.id,
|
|
||||||
),
|
|
||||||
Transaction.add_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
|
||||||
amount=27 * 2,
|
|
||||||
per_product=27,
|
|
||||||
product_count=2,
|
|
||||||
user_id=user.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
Transaction.buy_product(
|
|
||||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
|
||||||
product_count=1,
|
|
||||||
user_id=user2.id,
|
|
||||||
product_id=product.id,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
sql_session.add_all(transactions)
|
|
||||||
|
|
||||||
assert len(user_transactions(sql_session, user)) == 3
|
|
||||||
assert len(user_transactions(sql_session, user2)) == 2
|
|
||||||
Reference in New Issue
Block a user