Compare commits
63 Commits
event-sour
...
event-sour
| Author | SHA1 | Date | |
|---|---|---|---|
|
4c14a2cf65
|
|||
|
90da53c26c
|
|||
|
e9ce51b97b
|
|||
|
a4a22e6565
|
|||
|
bb7d1a2743
|
|||
|
e68d7effcd
|
|||
|
dc668ab113
|
|||
|
fa7ad3a258
|
|||
|
7f4a980eef
|
|||
|
2207001136
|
|||
|
fead6257c7
|
|||
|
2a9ace4263
|
|||
|
60fa6529ee
|
|||
|
2e66a9a4b0
|
|||
|
a087d3bede
|
|||
|
45bb31aba0
|
|||
|
f15c748558
|
|||
|
d220342d56
|
|||
|
108e17edb8
|
|||
|
12028cee22
|
|||
|
1515eb6dff
|
|||
|
7199cbf34a
|
|||
|
722f0cae93
|
|||
|
16be0f0fbe
|
|||
|
cec91d923c
|
|||
|
0504cc1a1e
|
|||
|
e7453d0fdd
|
|||
|
c6ecb6fae9
|
|||
|
aaa5a6c556
|
|||
|
6a83a41f28
|
|||
|
aa4e8dbee5
|
|||
|
f39e649b3d
|
|||
|
0a2fc799dd
|
|||
|
7d498f9bf1
|
|||
|
f1b15357f9
|
|||
|
de896901bb
|
|||
|
15d1763405
|
|||
|
683981d9dc
|
|||
|
4289d63ac9
|
|||
|
ce3e65357b
|
|||
|
928ab2a98a
|
|||
|
0b59d469dd
|
|||
|
24c5a9af38
|
|||
|
21ccf78401
|
|||
|
d5b481d97a
|
|||
|
cac1b5be20
|
|||
|
ad1fcfe98d
|
|||
|
cc7b40ab7e
|
|||
|
d35ffd04cc
|
|||
|
d39f1f8a92
|
|||
|
0e3bed9bf5
|
|||
|
3a1fc58a68
|
|||
|
1ec7c79378
|
|||
|
bc43d4948c
|
|||
|
7e5345c7fb
|
|||
|
50867db928
|
|||
|
5252cb611f
|
|||
|
5f510ee5d8
|
|||
|
f8829a6c7b
|
|||
|
885e989659
|
|||
|
5c0b2b5229
|
|||
|
9f2d8229fd
|
|||
|
8807d7278a
|
@@ -1,71 +0,0 @@
|
||||
name: Run benchmarks
|
||||
on:
|
||||
workflow_dispatch:
|
||||
# TODO: make this only workflow_dispatch when merged into main
|
||||
push:
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --group test
|
||||
|
||||
- name: Run benchmarks
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -x
|
||||
|
||||
PYTEST_ARGS=(
|
||||
-vv
|
||||
|
||||
--benchmark-only
|
||||
-k test_benchmark
|
||||
)
|
||||
|
||||
uv run -- pytest "${PYTEST_ARGS[@]}"
|
||||
|
||||
- name: Upload benchmark JSON report
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2
|
||||
with:
|
||||
source: ./benchmark/*/*.json
|
||||
quote-source: false
|
||||
target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
- name: Upload histograms
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v2
|
||||
with:
|
||||
source: ./benchmark/*.svg
|
||||
quote-source: false
|
||||
target: ${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
# NOTE: $GITHUB_STEP_SUMMARY when...
|
||||
- name: Run information
|
||||
run: |
|
||||
echo "Benchmark run ID: ${{ github.run_id }}"
|
||||
echo "Benchmark JSON: https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/benchmark.json"
|
||||
echo "Histograms: https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_owners.svg"
|
||||
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_price.svg"
|
||||
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-product_stock.svg"
|
||||
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-transaction_log.svg"
|
||||
echo " https://pages.pvv.ntnu.no/${{ gitea.repository }}/${{ gitea.ref_name }}/benchmark/${{ github.run_id }}/histogram-user_balance.svg"
|
||||
|
||||
- name: Check failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Tests failed"
|
||||
exit 1
|
||||
@@ -16,57 +16,66 @@ jobs:
|
||||
run-tests:
|
||||
runs-on: debian-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --group test
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --group test
|
||||
|
||||
- name: Run tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -x
|
||||
- name: Run tests
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set -x
|
||||
|
||||
PYTEST_ARGS=(
|
||||
-vv
|
||||
PYTEST_ARGS=(
|
||||
-vv
|
||||
|
||||
--cov=dibbler.lib
|
||||
--cov=dibbler.models
|
||||
--cov=dibbler.queries
|
||||
--cov-report=html
|
||||
--cov-branch
|
||||
|
||||
--self-contained-html
|
||||
--html=./test-report/index.html
|
||||
)
|
||||
|
||||
if [ "$DEBUG_SQL" == "true" ]; then
|
||||
PYTEST_ARGS+=(
|
||||
--debug-sql
|
||||
)
|
||||
fi
|
||||
|
||||
if [ "$DEBUG_SQL" == "true" ]; then
|
||||
PYTEST_ARGS+=(
|
||||
--debug-sql
|
||||
)
|
||||
fi
|
||||
uv run -- pytest "${PYTEST_ARGS[@]}"
|
||||
|
||||
uv run -- pytest "${PYTEST_ARGS[@]}"
|
||||
- name: Generate badge
|
||||
run: uv run -- coverage-badge -o htmlcov/badge.svg
|
||||
|
||||
- name: Generate badge
|
||||
run: uv run -- coverage-badge -o htmlcov/badge.svg
|
||||
- name: Upload test report
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
|
||||
with:
|
||||
source: ./test-report/
|
||||
target: ${{ gitea.ref_name }}/test-report/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
- name: Upload test report
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
|
||||
with:
|
||||
source: ./test-report/
|
||||
target: ${{ gitea.ref_name }}/test-report/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
- name: Upload coverage report
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
|
||||
with:
|
||||
source: ./htmlcov/
|
||||
target: ${{ gitea.ref_name }}/coverage/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
- name: Upload coverage report
|
||||
uses: https://git.pvv.ntnu.no/Projects/rsync-action@v1
|
||||
with:
|
||||
source: ./htmlcov/
|
||||
target: ${{ gitea.ref_name }}/coverage/
|
||||
username: gitea-web
|
||||
ssh-key: ${{ secrets.WEB_SYNC_SSH_KEY }}
|
||||
host: pages.pvv.ntnu.no
|
||||
known-hosts: "pages.pvv.ntnu.no ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIH2QjfFB+city1SYqltkVqWACfo1j37k+oQQfj13mtgg"
|
||||
|
||||
- name: Check failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Tests failed"
|
||||
exit 1
|
||||
- name: Check failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Tests failed"
|
||||
exit 1
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -8,10 +8,6 @@ test.db
|
||||
|
||||
.ruff_cache
|
||||
|
||||
*.qcow2
|
||||
|
||||
.coverage
|
||||
.coverage.*
|
||||
htmlcov
|
||||
test-report
|
||||
/benchmark
|
||||
|
||||
31
README.md
31
README.md
@@ -24,7 +24,6 @@ Deretter kan du kjøre programmet med
|
||||
|
||||
```console
|
||||
python -m dibbler -c example-config.ini create-db
|
||||
python -m dibbler -c example-config.ini seed-data
|
||||
python -m dibbler -c example-config.ini loop
|
||||
```
|
||||
|
||||
@@ -62,21 +61,25 @@ Her ligger enhetstester for prosjektet. Testene bruker `pytest` som testløper.
|
||||
|
||||
## Nix
|
||||
|
||||
Du kan enklest komme i gang med nix-utvikling ved å kjøre VM-en:
|
||||
### Bygge nytt image
|
||||
|
||||
```console
|
||||
nix run .#vm
|
||||
For å bygge et image trenger du en builder som takler å bygge for arkitekturen du skal lage et image for.
|
||||
|
||||
# Eller hvis du trenger tilgang til terminalen i VM-en også:
|
||||
nix run .#vm-non-kiosk
|
||||
```
|
||||
(Eller be til gudene om at cross compile funker)
|
||||
|
||||
Du kan også bygge pakken manuelt, eller kjøre den direkte:
|
||||
Flaket exposer en modul som autologger inn med en bruker som automatisk kjører dibbler, og setter opp et minimalistisk miljø.
|
||||
|
||||
```console
|
||||
nix build .#dibbler
|
||||
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.**
|
||||
|
||||
nix run .# -- --config example-config.ini create-db
|
||||
nix run .# -- --config example-config.ini seed-data
|
||||
nix run .# -- --config example-config.ini loop
|
||||
```
|
||||
Du kan også endre hvilken config-fil som blir brukt direkte i pakken eller i modulen.
|
||||
|
||||
Se eksempelet for hvordan skrot er satt opp i `flake.nix` og `nix/skrott.nix`
|
||||
|
||||
### Bygge image for skrot
|
||||
|
||||
Skrot har et image definert i flake.nix:
|
||||
|
||||
1. endre `example-config.ini`
|
||||
2. `nix build .#images.skrot`
|
||||
3. ???
|
||||
4. non-profit
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
# This module is supposed to act as a singleton and be filled
|
||||
# with config variables by cli.py
|
||||
from pathlib import Path
|
||||
import os
|
||||
|
||||
import configparser
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
|
||||
DEFAULT_CONFIG_PATH = Path('/etc/dibbler/dibbler.conf')
|
||||
|
||||
def default_config_path_submissive_and_readable() -> bool:
|
||||
return DEFAULT_CONFIG_PATH.is_file() and any(
|
||||
[
|
||||
(
|
||||
DEFAULT_CONFIG_PATH.stat().st_mode & 0o400
|
||||
and DEFAULT_CONFIG_PATH.stat().st_uid == os.getuid()
|
||||
),
|
||||
(
|
||||
DEFAULT_CONFIG_PATH.stat().st_mode & 0o040
|
||||
and DEFAULT_CONFIG_PATH.stat().st_gid == os.getgid()
|
||||
),
|
||||
(DEFAULT_CONFIG_PATH.stat().st_mode & 0o004),
|
||||
]
|
||||
)
|
||||
|
||||
22
dibbler/lib/query_helpers.py
Normal file
22
dibbler/lib/query_helpers.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from typing import TypeVar
|
||||
from sqlalchemy import BindParameter, literal
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def const(value: T) -> BindParameter[T]:
|
||||
"""
|
||||
Create a constant SQL literal bind parameter.
|
||||
|
||||
This is useful to avoid too many `?` bind parameters in SQL queries,
|
||||
when the input value is known to be safe.
|
||||
"""
|
||||
|
||||
return literal(value, literal_execute=True)
|
||||
|
||||
|
||||
CONST_ZERO: BindParameter[int] = const(0)
|
||||
CONST_ONE: BindParameter[int] = const(1)
|
||||
CONST_TRUE: BindParameter[bool] = const(True)
|
||||
CONST_FALSE: BindParameter[bool] = const(False)
|
||||
CONST_NONE: BindParameter[None] = const(None)
|
||||
@@ -1,4 +1,3 @@
|
||||
from dibbler.lib.render_tree import render_tree
|
||||
from dibbler.models import Transaction, TransactionType
|
||||
from dibbler.models.Transaction import EXPECTED_FIELDS
|
||||
|
||||
@@ -11,19 +10,23 @@ def render_transaction_log(transaction_log: list[Transaction]) -> str:
|
||||
aggregated_log = _aggregate_joint_transactions(transaction_log)
|
||||
|
||||
lines = []
|
||||
for transaction in aggregated_log:
|
||||
|
||||
for i, transaction in enumerate(aggregated_log):
|
||||
if isinstance(transaction, list):
|
||||
inner_lines = []
|
||||
lines.append(_render_transaction(transaction[0]))
|
||||
for sub_transaction in transaction[1:]:
|
||||
line = _render_transaction(sub_transaction)
|
||||
is_last = i == len(aggregated_log) - 1
|
||||
lines.append(_render_transaction(transaction[0], is_last))
|
||||
for j, sub_transaction in enumerate(transaction[1:]):
|
||||
is_last_inner = j == len(transaction) - 2
|
||||
line = _render_transaction(sub_transaction, is_last_inner)
|
||||
inner_lines.append(line)
|
||||
lines.append(inner_lines)
|
||||
indented_inner_lines = _indent_lines(inner_lines, is_last=is_last)
|
||||
lines.extend(indented_inner_lines)
|
||||
else:
|
||||
line = _render_transaction(transaction)
|
||||
is_last = i == len(aggregated_log) - 1
|
||||
line = _render_transaction(transaction, is_last)
|
||||
lines.append(line)
|
||||
|
||||
return render_tree(lines)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _aggregate_joint_transactions(
|
||||
@@ -58,7 +61,17 @@ def _aggregate_joint_transactions(
|
||||
return aggregated
|
||||
|
||||
|
||||
def _render_transaction(transaction: Transaction) -> str:
|
||||
def _indent_lines(lines: list[str], is_last: bool = False) -> list[str]:
|
||||
indented_lines = []
|
||||
for line in lines:
|
||||
if is_last:
|
||||
indented_lines.append(" " + line)
|
||||
else:
|
||||
indented_lines.append("│ " + line)
|
||||
return indented_lines
|
||||
|
||||
|
||||
def _render_transaction(transaction: Transaction, is_last: bool) -> str:
|
||||
match transaction.type_:
|
||||
case TransactionType.ADD_PRODUCT:
|
||||
line = f"ADD_PRODUCT({transaction.id}, {transaction.user.name}"
|
||||
@@ -112,4 +125,5 @@ def _render_transaction(transaction: Transaction) -> str:
|
||||
line = (
|
||||
f"UNKNOWN[{transaction.type_}](id={transaction.id}, user_id={transaction.user_id})"
|
||||
)
|
||||
return line
|
||||
|
||||
return "└─ " + line if is_last else "├─ " + line
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
_TREE_CHARS = {
|
||||
"normal": {
|
||||
"vertical": "│ ",
|
||||
"branch": "├─ ",
|
||||
"last": "└─ ",
|
||||
"empty": " ",
|
||||
},
|
||||
"ascii": {
|
||||
"vertical": "| ",
|
||||
"branch": "|-- ",
|
||||
"last": "`-- ",
|
||||
"empty": " ",
|
||||
},
|
||||
}
|
||||
|
||||
assert set(_TREE_CHARS["normal"].keys()) == set(_TREE_CHARS["ascii"].keys())
|
||||
assert all(len(v) == 3 for v in _TREE_CHARS["normal"].values())
|
||||
assert all(len(v) == 4 for v in _TREE_CHARS["ascii"].values())
|
||||
|
||||
|
||||
def render_tree(
|
||||
tree: list[str | list],
|
||||
ascii_only: bool = False,
|
||||
) -> str:
|
||||
"""
|
||||
Render a tree structure as a string.
|
||||
|
||||
Each item in the `tree` list can be either a string (a leaf node)
|
||||
or another list (a subtree).
|
||||
|
||||
When `ascii_only` is `True`, only ASCII characters are used for drawing the tree.
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
tree = [
|
||||
"root",
|
||||
[
|
||||
"child1",
|
||||
[
|
||||
"grandchild1",
|
||||
"grandchild2",
|
||||
],
|
||||
"child2",
|
||||
],
|
||||
"root2",
|
||||
]
|
||||
print(render_tree(tree, ascii_only=False))
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
├─ root
|
||||
│ ├─ child1
|
||||
│ │ ├─ grandchild1
|
||||
│ │ └─ grandchild2
|
||||
│ └─ child2
|
||||
└─ root2
|
||||
```
|
||||
|
||||
Example with ASCII only:
|
||||
|
||||
```python
|
||||
print(render_tree(tree, ascii_only=True))
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
|-- root
|
||||
| |-- child1
|
||||
| | |-- grandchild1
|
||||
| | `-- grandchild2
|
||||
| `-- child2
|
||||
`-- root2
|
||||
```
|
||||
"""
|
||||
|
||||
result: list[str] = []
|
||||
for index, item in enumerate(tree):
|
||||
is_last = index == len(tree) - 1
|
||||
item_lines = _render_tree_line(item, is_last, ascii_only)
|
||||
result.extend(item_lines)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def _render_tree_line(
|
||||
item: str | list,
|
||||
is_last: bool,
|
||||
ascii_only: bool,
|
||||
prefix: str = "",
|
||||
) -> list[str]:
|
||||
chars = _TREE_CHARS["ascii"] if ascii_only else _TREE_CHARS["normal"]
|
||||
lines: list[str] = []
|
||||
|
||||
if isinstance(item, str):
|
||||
line_prefix = chars["last"] if is_last else chars["branch"]
|
||||
item_lines = item.splitlines()
|
||||
for line_index, line in enumerate(item_lines):
|
||||
if line_index == 0:
|
||||
lines.append(f"{prefix}{line_prefix}{line}")
|
||||
else:
|
||||
lines.append(f"{prefix}{chars['vertical']}{line}")
|
||||
|
||||
elif isinstance(item, list):
|
||||
new_prefix = prefix + (chars["empty"] if is_last else chars["vertical"])
|
||||
for sub_index, sub_item in enumerate(item):
|
||||
sub_is_last = sub_index == len(item) - 1
|
||||
sub_lines = _render_tree_line(sub_item, sub_is_last, ascii_only, new_prefix)
|
||||
lines.extend(sub_lines)
|
||||
else:
|
||||
raise ValueError("Item must be either a string or a list.")
|
||||
|
||||
return lines
|
||||
@@ -1,8 +1,7 @@
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from dibbler.conf import DEFAULT_CONFIG_PATH, config, default_config_path_submissive_and_readable
|
||||
from dibbler.conf import config
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
|
||||
@@ -29,13 +28,7 @@ subparsers.add_parser("transaction-log", help="Print transaction log")
|
||||
|
||||
def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.config is not None:
|
||||
config.read(args.config)
|
||||
elif default_config_path_submissive_and_readable():
|
||||
config.read(DEFAULT_CONFIG_PATH)
|
||||
else:
|
||||
print("Could not read config file, it was neither provided nor readable in default location", file=sys.stderr)
|
||||
config.read(args.config)
|
||||
|
||||
if args.subcommand == "loop":
|
||||
import dibbler.subcommands.loop as loop
|
||||
|
||||
@@ -17,19 +17,16 @@ def _pascal_case_to_snake_case(name: str) -> str:
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(
|
||||
naming_convention={
|
||||
"ix": "ix__%(table_name)s__%(column_0_label)s",
|
||||
"uq": "uq__%(table_name)s__%(column_0_name)s",
|
||||
"ck": "ck__%(table_name)s__%(constraint_name)s",
|
||||
"fk": "fk__%(table_name)s__%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk__%(table_name)s",
|
||||
"ix": "ix_%(table_name)s_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
)
|
||||
|
||||
@declared_attr.directive
|
||||
def __tablename__(cls) -> str:
|
||||
if hasattr(cls, "__table_name__"):
|
||||
assert isinstance(cls.__table_name__, str)
|
||||
return cls.__table_name__
|
||||
return _pascal_case_to_snake_case(cls.__name__)
|
||||
|
||||
# NOTE: This is the default implementation of __repr__ for all tables,
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from dibbler.models import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dibbler.models import Transaction
|
||||
|
||||
|
||||
class LastCacheTransaction(Base):
|
||||
"""Tracks the last transaction that affected various caches."""
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
"""Internal database ID"""
|
||||
|
||||
transaction_id: Mapped[int | None] = mapped_column(ForeignKey("trx.id"), index=True)
|
||||
"""The ID of the last transaction that affected the cache(s)."""
|
||||
|
||||
transaction: Mapped[Transaction | None] = relationship(
|
||||
lazy="joined",
|
||||
foreign_keys=[transaction_id],
|
||||
)
|
||||
"""The last transaction that affected the cache(s)."""
|
||||
@@ -19,7 +19,6 @@ class Product(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
"""Internal database ID"""
|
||||
|
||||
# TODO: add more validation for barcode
|
||||
bar_code: Mapped[str] = mapped_column(String(13), unique=True)
|
||||
"""
|
||||
The bar code of the product.
|
||||
|
||||
@@ -1,30 +1,16 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import Integer, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from dibbler.models import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dibbler.models import LastCacheTransaction, Product
|
||||
|
||||
|
||||
class ProductCache(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
"""Internal database ID"""
|
||||
|
||||
product_id: Mapped[int] = mapped_column(ForeignKey('product.id'))
|
||||
product: Mapped[Product] = relationship(
|
||||
lazy="joined",
|
||||
foreign_keys=[product_id],
|
||||
)
|
||||
product_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
price: Mapped[int] = mapped_column(Integer)
|
||||
stock: Mapped[int] = mapped_column(Integer)
|
||||
price_timestamp: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
last_cache_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("last_cache_transaction.id"), nullable=True)
|
||||
last_cache_transaction: Mapped[LastCacheTransaction | None] = relationship(
|
||||
lazy="joined",
|
||||
foreign_keys=[last_cache_transaction_id],
|
||||
)
|
||||
stock: Mapped[int] = mapped_column(Integer)
|
||||
stock_timestamp: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
@@ -33,10 +33,11 @@ if TYPE_CHECKING:
|
||||
from .Product import Product
|
||||
from .User import User
|
||||
|
||||
# TODO: rename to *_PERCENT
|
||||
# NOTE: these only matter when there are no adjustments made in the database.
|
||||
DEFAULT_INTEREST_RATE_PERCENT = 100
|
||||
DEFAULT_INTEREST_RATE_PERCENTAGE = 100
|
||||
DEFAULT_PENALTY_THRESHOLD = -100
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT = 200
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE = 200
|
||||
|
||||
_DYNAMIC_FIELDS: set[str] = {
|
||||
"amount",
|
||||
@@ -87,7 +88,6 @@ def _transaction_type_field_constraints(
|
||||
|
||||
|
||||
class Transaction(Base):
|
||||
__tablename__ = "trx"
|
||||
__table_args__ = (
|
||||
*[
|
||||
_transaction_type_field_constraints(transaction_type, expected_fields)
|
||||
@@ -131,15 +131,12 @@ class Transaction(Base):
|
||||
),
|
||||
name="trx_joint_transaction_id_not_self",
|
||||
),
|
||||
|
||||
# Speed up product stock calculation
|
||||
Index("ix__transaction__product_id_type_time", "product_id", "type", "time"),
|
||||
|
||||
# Speed up product count calculation
|
||||
Index("product_user_time", "product_id", "user_id", "time"),
|
||||
# Speed up product owner calculation
|
||||
Index("ix__transaction__user_id_product_time", "user_id", "product_id", "time"),
|
||||
|
||||
Index("user_product_time", "user_id", "product_id", "time"),
|
||||
# Speed up user transaction list / credit calculation
|
||||
Index("ix__transaction__user_id_time", "user_id", "time"),
|
||||
Index("user_time", "user_id", "time"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
@@ -149,7 +146,7 @@ class Transaction(Base):
|
||||
Not used for anything else than identifying the transaction in the database.
|
||||
"""
|
||||
|
||||
time: Mapped[datetime] = mapped_column(DateTime, index=True)
|
||||
time: Mapped[datetime] = mapped_column(DateTime)
|
||||
"""
|
||||
The time when the transaction took place.
|
||||
|
||||
@@ -165,7 +162,7 @@ class Transaction(Base):
|
||||
This is not used for any calculations, but can be useful for debugging.
|
||||
"""
|
||||
|
||||
type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type", index=True)
|
||||
type_: Mapped[TransactionType] = mapped_column(TransactionTypeSQL, name="type")
|
||||
"""
|
||||
Which type of transaction this is.
|
||||
|
||||
@@ -192,7 +189,7 @@ class Transaction(Base):
|
||||
that the user paid in the store would be stored in the `amount` field.
|
||||
"""
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"), index=True)
|
||||
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",
|
||||
@@ -210,10 +207,7 @@ class Transaction(Base):
|
||||
In the case of `JOINT` transactions, this is the user who initiated the joint transaction.
|
||||
"""
|
||||
|
||||
joint_transaction_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("trx.id"),
|
||||
index=True,
|
||||
)
|
||||
joint_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("transaction.id"))
|
||||
"""
|
||||
An optional ID to group multiple transactions together as part of a joint transaction.
|
||||
|
||||
@@ -229,7 +223,7 @@ class Transaction(Base):
|
||||
"""
|
||||
|
||||
# Receiving user when moving credit from one user to another
|
||||
transfer_user_id: Mapped[int | None] = mapped_column(ForeignKey("user.id"), index=True)
|
||||
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",
|
||||
@@ -238,7 +232,7 @@ class Transaction(Base):
|
||||
"""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"), index=True)
|
||||
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."""
|
||||
@@ -336,6 +330,7 @@ class Transaction(Base):
|
||||
Validates the transaction's fields based on its type.
|
||||
Raises `ValueError` if the transaction is invalid.
|
||||
"""
|
||||
# TODO: do we allow free products?
|
||||
if self.amount == 0:
|
||||
raise ValueError("Amount must not be zero.")
|
||||
|
||||
@@ -405,11 +400,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating an `ADJUST_BALANCE` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.ADJUST_BALANCE,
|
||||
@@ -426,14 +416,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating an `ADJUST_INTEREST` transaction.
|
||||
|
||||
Note that the `interest_rate_percent` is absolute, not relative to the previous interest rate.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.ADJUST_INTEREST,
|
||||
@@ -451,14 +433,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating an `ADJUST_PENALTY` transaction.
|
||||
|
||||
Note that both `penalty_multiplier_percent` and `penalty_threshold` are absolute,
|
||||
not relative to the previous settings.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.ADJUST_PENALTY,
|
||||
@@ -477,11 +451,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating an `ADJUST_STOCK` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.ADJUST_STOCK,
|
||||
@@ -502,11 +471,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating an `ADD_PRODUCT` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.ADD_PRODUCT,
|
||||
@@ -527,11 +491,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating a `BUY_PRODUCT` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.BUY_PRODUCT,
|
||||
@@ -550,11 +509,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating a `JOINT` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.JOINT,
|
||||
@@ -572,11 +526,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating a `JOINT_BUY_PRODUCT` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.JOINT_BUY_PRODUCT,
|
||||
@@ -594,11 +543,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating a `TRANSFER` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.TRANSFER,
|
||||
@@ -617,11 +561,6 @@ class Transaction(Base):
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Self:
|
||||
"""
|
||||
Convenience constructor for creating a `THROW_PRODUCT` transaction.
|
||||
|
||||
Should NOT be used directly in the application code; use the various queries instead.
|
||||
"""
|
||||
return cls(
|
||||
time=time,
|
||||
type_=TransactionType.THROW_PRODUCT,
|
||||
|
||||
@@ -1,30 +1,14 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Integer, ForeignKey
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy import Integer, DateTime
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from dibbler.models import Base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from dibbler.models import LastCacheTransaction, User
|
||||
|
||||
|
||||
# More like user balance cash money flow, amirite?
|
||||
class UserCache(Base):
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
"""internal database id"""
|
||||
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey('user.id'))
|
||||
user: Mapped[User] = relationship(
|
||||
lazy="joined",
|
||||
foreign_keys=[user_id],
|
||||
)
|
||||
class UserBalanceCache(Base):
|
||||
user_id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
|
||||
balance: Mapped[int] = mapped_column(Integer)
|
||||
|
||||
last_cache_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("last_cache_transaction.id"), nullable=True)
|
||||
last_cache_transaction: Mapped[LastCacheTransaction | None] = relationship(
|
||||
lazy="joined",
|
||||
foreign_keys=[last_cache_transaction_id],
|
||||
)
|
||||
timestamp: Mapped[datetime] = mapped_column(DateTime)
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
__all__ = [
|
||||
"Base",
|
||||
"LastCacheTransaction",
|
||||
"Product",
|
||||
"ProductCache",
|
||||
"Transaction",
|
||||
"TransactionType",
|
||||
"User",
|
||||
"UserCache",
|
||||
]
|
||||
|
||||
from .Base import Base
|
||||
from .LastCacheTransaction import LastCacheTransaction
|
||||
from .Product import Product
|
||||
from .ProductCache import ProductCache
|
||||
from .Transaction import Transaction
|
||||
from .TransactionType import TransactionType
|
||||
from .User import User
|
||||
from .UserCache import UserCache
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
__all__ = [
|
||||
"add_product",
|
||||
"adjust_balance",
|
||||
# "add_product",
|
||||
# "add_user",
|
||||
"adjust_interest",
|
||||
"adjust_penalty",
|
||||
"adjust_stock",
|
||||
"affected_products",
|
||||
"affected_users",
|
||||
"create_product",
|
||||
"create_user",
|
||||
"current_interest",
|
||||
"current_penalty",
|
||||
"joint_buy_product",
|
||||
@@ -16,37 +11,27 @@ __all__ = [
|
||||
"product_price",
|
||||
"product_price_log",
|
||||
"product_stock",
|
||||
# "products_owned_by_user",
|
||||
"search_product",
|
||||
"search_user",
|
||||
"throw_product",
|
||||
"transaction_log",
|
||||
"transfer",
|
||||
"update_cache",
|
||||
"user_balance",
|
||||
"user_balance_log",
|
||||
"user_products",
|
||||
]
|
||||
|
||||
from .add_product import add_product
|
||||
from .adjust_balance import adjust_balance
|
||||
# from .add_product import add_product
|
||||
# from .add_user import add_user
|
||||
from .adjust_interest import adjust_interest
|
||||
from .adjust_penalty import adjust_penalty
|
||||
from .adjust_stock import adjust_stock
|
||||
from .affected_products import affected_products
|
||||
from .affected_users import affected_users
|
||||
from .create_product import create_product
|
||||
from .create_user import create_user
|
||||
from .current_interest import current_interest
|
||||
from .current_penalty import current_penalty
|
||||
from .joint_buy_product import joint_buy_product
|
||||
from .product_owners import product_owners, product_owners_log
|
||||
from .product_price import product_price, product_price_log
|
||||
from .product_stock import product_stock
|
||||
|
||||
# from .products_owned_by_user import products_owned_by_user
|
||||
from .search_product import search_product
|
||||
from .search_user import search_user
|
||||
from .throw_product import throw_product
|
||||
from .transaction_log import transaction_log
|
||||
from .transfer import transfer
|
||||
from .update_cache import update_cache
|
||||
from .user_balance import user_balance, user_balance_log
|
||||
from .user_products import user_products
|
||||
|
||||
@@ -1,51 +1 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
|
||||
|
||||
def add_product(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
product: Product,
|
||||
amount: int,
|
||||
per_product: int,
|
||||
product_count: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount must be positive.")
|
||||
|
||||
if per_product <= 0:
|
||||
raise ValueError("Per product price must be positive.")
|
||||
|
||||
if product_count <= 0:
|
||||
raise ValueError("Product count must be positive.")
|
||||
|
||||
if per_product * product_count < amount:
|
||||
raise ValueError("Total per product price must be at least equal to amount.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.add_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=amount,
|
||||
per_product=per_product,
|
||||
product_count=product_count,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
# TODO: implement me
|
||||
|
||||
1
dibbler/queries/add_user.py
Normal file
1
dibbler/queries/add_user.py
Normal file
@@ -0,0 +1 @@
|
||||
# TODO: implement me
|
||||
@@ -1,33 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
|
||||
|
||||
def adjust_balance(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
amount: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if amount == 0:
|
||||
raise ValueError("Amount must be non-zero.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=amount,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
@@ -1,5 +1,3 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
@@ -12,25 +10,19 @@ def adjust_interest(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
new_interest: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
) -> None:
|
||||
if new_interest < 0:
|
||||
raise ValueError("Interest rate cannot be negative")
|
||||
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.adjust_interest(
|
||||
user_id=user.id,
|
||||
interest_rate_percent=new_interest,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
@@ -14,9 +12,8 @@ def adjust_penalty(
|
||||
user: User,
|
||||
new_penalty: int | None = None,
|
||||
new_penalty_multiplier: int | None = None,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
) -> None:
|
||||
if new_penalty is None and new_penalty_multiplier is None:
|
||||
raise ValueError("At least one of new_penalty or new_penalty_multiplier must be provided")
|
||||
|
||||
@@ -33,17 +30,12 @@ def adjust_penalty(
|
||||
if new_penalty_multiplier is None:
|
||||
new_penalty_multiplier = existing_penalty_multiplier
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.adjust_penalty(
|
||||
user_id=user.id,
|
||||
penalty_threshold=new_penalty,
|
||||
penalty_multiplier_percent=new_penalty_multiplier,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
|
||||
|
||||
def adjust_stock(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
product: Product,
|
||||
product_count: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if product_count == 0:
|
||||
raise ValueError("Product count must be non-zero.")
|
||||
|
||||
# TODO: it should not be possible to reduce stock below zero.
|
||||
#
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.adjust_stock(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=product_count,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
@@ -1,88 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BindParameter, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, TransactionType
|
||||
from dibbler.queries.query_helpers import until_filter, after_filter
|
||||
|
||||
|
||||
def affected_products(
|
||||
sql_session: Session,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: BindParameter[Transaction] | Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
after_time: BindParameter[datetime] | datetime | None = None,
|
||||
after_transaction: Transaction | None = None,
|
||||
after_inclusive: bool = True,
|
||||
) -> set[Product]:
|
||||
"""
|
||||
Get all products where attributes were affected over a given interval.
|
||||
"""
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
if not (after_time is None or after_transaction is None):
|
||||
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
|
||||
|
||||
if isinstance(after_time, datetime):
|
||||
after_time = BindParameter("after_time", value=after_time)
|
||||
|
||||
if isinstance(after_transaction, Transaction):
|
||||
if after_transaction.id is None:
|
||||
raise ValueError("after_transaction must be persisted in the database.")
|
||||
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
|
||||
else:
|
||||
after_transaction_id = None
|
||||
|
||||
if after_time is not None and until_time is not None:
|
||||
assert isinstance(after_time.value, datetime)
|
||||
assert isinstance(until_time.value, datetime)
|
||||
|
||||
if after_time.value > until_time.value:
|
||||
raise ValueError("after_time cannot be after until_time.")
|
||||
|
||||
if after_transaction is not None and until_transaction is not None:
|
||||
assert after_transaction.time is not None
|
||||
assert until_transaction.time is not None
|
||||
|
||||
if after_transaction.time > until_transaction.time:
|
||||
raise ValueError("after_transaction cannot be after until_transaction.")
|
||||
|
||||
result = sql_session.scalars(
|
||||
select(Product)
|
||||
.distinct()
|
||||
.join(Transaction, Product.id == Transaction.product_id)
|
||||
.where(
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.JOINT.as_literal_column(),
|
||||
TransactionType.THROW_PRODUCT.as_literal_column(),
|
||||
]
|
||||
),
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
after_filter(
|
||||
after_time=after_time,
|
||||
after_transaction_id=after_transaction_id,
|
||||
after_inclusive=after_inclusive,
|
||||
),
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
).all()
|
||||
|
||||
return set(result)
|
||||
@@ -1,86 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BindParameter, select, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, TransactionType, User
|
||||
from dibbler.queries.query_helpers import until_filter, after_filter
|
||||
|
||||
def affected_users(
|
||||
sql_session: Session,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: BindParameter[Transaction] | Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
after_time: BindParameter[datetime] | datetime | None = None,
|
||||
after_transaction: Transaction | None = None,
|
||||
after_inclusive: bool = True,
|
||||
) -> set[User]:
|
||||
"""
|
||||
Get all users where attributes were affected over a given interval.
|
||||
"""
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
if not (after_time is None or after_transaction is None):
|
||||
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
|
||||
|
||||
if isinstance(after_time, datetime):
|
||||
after_time = BindParameter("after_time", value=after_time)
|
||||
|
||||
if isinstance(after_transaction, Transaction):
|
||||
if after_transaction.id is None:
|
||||
raise ValueError("after_transaction must be persisted in the database.")
|
||||
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
|
||||
else:
|
||||
after_transaction_id = None
|
||||
|
||||
if after_time is not None and until_time is not None:
|
||||
assert isinstance(after_time.value, datetime)
|
||||
assert isinstance(until_time.value, datetime)
|
||||
|
||||
if after_time.value > until_time.value:
|
||||
raise ValueError("after_time cannot be after until_time.")
|
||||
|
||||
if after_transaction is not None and until_transaction is not None:
|
||||
assert after_transaction.time is not None
|
||||
assert until_transaction.time is not None
|
||||
|
||||
if after_transaction.time > until_transaction.time:
|
||||
raise ValueError("after_transaction cannot be after until_transaction.")
|
||||
|
||||
result = sql_session.scalars(
|
||||
select(User)
|
||||
.distinct()
|
||||
.join(Transaction, or_(User.id == Transaction.user_id, User.id == Transaction.transfer_user_id))
|
||||
.where(
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_BALANCE.as_literal_column(),
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.TRANSFER.as_literal_column(),
|
||||
]
|
||||
),
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
after_filter(
|
||||
after_time=after_time,
|
||||
after_transaction_id=after_transaction_id,
|
||||
after_inclusive=after_inclusive,
|
||||
),
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
).all()
|
||||
|
||||
return set(result)
|
||||
@@ -1,38 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
|
||||
|
||||
def buy_product(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
product: Product,
|
||||
product_count: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if product_count <= 0:
|
||||
raise ValueError("Product count must be positive.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.buy_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=product_count,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
@@ -1,25 +0,0 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product
|
||||
|
||||
|
||||
def create_product(
|
||||
sql_session: Session,
|
||||
name: str,
|
||||
barcode: str,
|
||||
) -> Product:
|
||||
if not name:
|
||||
raise ValueError("Name cannot be empty.")
|
||||
|
||||
if not barcode:
|
||||
raise ValueError("Barcode cannot be empty.")
|
||||
|
||||
# TODO: check for duplicate names, barcodes
|
||||
|
||||
# TODO: add more validation for barcode
|
||||
|
||||
product = Product(barcode, name)
|
||||
sql_session.add(product)
|
||||
sql_session.commit()
|
||||
|
||||
return product
|
||||
@@ -1,21 +0,0 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import User
|
||||
|
||||
|
||||
def create_user(
|
||||
sql_session: Session,
|
||||
name: str,
|
||||
card: str | None,
|
||||
rfid: str | None,
|
||||
) -> User:
|
||||
if not name:
|
||||
raise ValueError("Name cannot be empty.")
|
||||
|
||||
# TODO: check for duplicate names, cards, rfids
|
||||
|
||||
user = User(name=name, card=card, rfid=rfid)
|
||||
sql_session.add(user)
|
||||
sql_session.commit()
|
||||
|
||||
return user
|
||||
@@ -1,55 +1,24 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BindParameter, bindparam, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, TransactionType
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENT
|
||||
from dibbler.queries.query_helpers import until_filter
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
|
||||
|
||||
def current_interest(
|
||||
sql_session: Session,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: BindParameter[Transaction] | Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
) -> int:
|
||||
"""
|
||||
Get the current interest rate percentage as of a given time or transaction.
|
||||
|
||||
Returns the interest rate percentage as an integer.
|
||||
"""
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
# TODO: add until transaction parameter
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def current_interest(sql_session: Session) -> int:
|
||||
result = sql_session.scalars(
|
||||
select(Transaction)
|
||||
.where(
|
||||
Transaction.type_ == TransactionType.ADJUST_INTEREST,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
)
|
||||
.where(Transaction.type_ == TransactionType.ADJUST_INTEREST)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(1)
|
||||
).one_or_none()
|
||||
|
||||
if result is None:
|
||||
return DEFAULT_INTEREST_RATE_PERCENT
|
||||
return DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
elif result.interest_rate_percent is None:
|
||||
return DEFAULT_INTEREST_RATE_PERCENT
|
||||
return DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
else:
|
||||
return result.interest_rate_percent
|
||||
|
||||
@@ -1,57 +1,26 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BindParameter, bindparam, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, TransactionType
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
)
|
||||
from dibbler.queries.query_helpers import until_filter
|
||||
|
||||
|
||||
def current_penalty(
|
||||
sql_session: Session,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: BindParameter[Transaction] | Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
) -> tuple[int, int]:
|
||||
"""
|
||||
Get the current penalty settings (threshold and multiplier percentage) as of a given time or transaction.
|
||||
|
||||
Returns a tuple of `(penalty_threshold, penalty_multiplier_percentage)`.
|
||||
"""
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
# TODO: add until transaction parameter
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def current_penalty(sql_session: Session) -> tuple[int, int]:
|
||||
result = sql_session.scalars(
|
||||
select(Transaction)
|
||||
.where(
|
||||
Transaction.type_ == TransactionType.ADJUST_PENALTY,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
)
|
||||
.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_PERCENT
|
||||
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"
|
||||
|
||||
@@ -17,7 +17,7 @@ def joint_buy_product(
|
||||
users: list[User],
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> list[Transaction]:
|
||||
) -> None:
|
||||
"""
|
||||
Create buy product transactions for multiple users at once.
|
||||
"""
|
||||
@@ -25,23 +25,15 @@ def joint_buy_product(
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if instigator.id is None:
|
||||
raise ValueError("Instigator must be persisted in the database.")
|
||||
|
||||
if len(users) == 0:
|
||||
raise ValueError("At least bying one user must be specified.")
|
||||
if instigator not in users:
|
||||
raise ValueError("Instigator must be in the list of users buying the product.")
|
||||
|
||||
if any(user.id is None for user in users):
|
||||
raise ValueError("All users must be persisted in the database.")
|
||||
|
||||
if instigator not in users:
|
||||
raise ValueError("Instigator must be in the list of users buying the product.")
|
||||
|
||||
if product_count <= 0:
|
||||
raise ValueError("Product count must be positive.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
joint_transaction = Transaction.joint(
|
||||
user_id=instigator.id,
|
||||
product_id=product.id,
|
||||
@@ -52,8 +44,6 @@ def joint_buy_product(
|
||||
sql_session.add(joint_transaction)
|
||||
sql_session.flush() # Ensure joint_transaction gets an ID
|
||||
|
||||
transactions = [joint_transaction]
|
||||
|
||||
for user in users:
|
||||
buy_transaction = Transaction.joint_buy_product(
|
||||
user_id=user.id,
|
||||
@@ -62,7 +52,5 @@ def joint_buy_product(
|
||||
message=message,
|
||||
)
|
||||
sql_session.add(buy_transaction)
|
||||
transactions.append(buy_transaction)
|
||||
|
||||
sql_session.commit()
|
||||
return transactions
|
||||
|
||||
@@ -8,11 +8,11 @@ from sqlalchemy import (
|
||||
bindparam,
|
||||
case,
|
||||
func,
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
@@ -20,22 +20,13 @@ from dibbler.models import (
|
||||
User,
|
||||
)
|
||||
from dibbler.queries.product_stock import _product_stock_query
|
||||
from dibbler.queries.query_helpers import (
|
||||
CONST_NONE,
|
||||
CONST_ONE,
|
||||
CONST_ZERO,
|
||||
until_filter,
|
||||
)
|
||||
|
||||
|
||||
def _product_owners_query(
|
||||
product_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: BindParameter[datetime] | datetime | None = None,
|
||||
cte_name: str = "rec_cte",
|
||||
trx_subset_name: str = "trx_subset",
|
||||
) -> CTE:
|
||||
"""
|
||||
The inner query for inferring the owners of a given product.
|
||||
@@ -47,25 +38,13 @@ def _product_owners_query(
|
||||
if isinstance(product_id, int):
|
||||
product_id = bindparam("product_id", value=product_id)
|
||||
|
||||
if until_time is not None and until_transaction is not None:
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = bindparam("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until)
|
||||
|
||||
product_stock = _product_stock_query(
|
||||
product_id=product_id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until,
|
||||
)
|
||||
|
||||
# Subset of transactions that we'll want to iterate over.
|
||||
@@ -78,23 +57,22 @@ def _product_owners_query(
|
||||
Transaction.user_id,
|
||||
Transaction.product_count,
|
||||
)
|
||||
# TODO: maybe add value constraint on ADJUST_STOCK?
|
||||
.where(
|
||||
or_(
|
||||
Transaction.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
and_(
|
||||
Transaction.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
Transaction.product_count > CONST_ZERO,
|
||||
),
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
# TransactionType.BUY_PRODUCT,
|
||||
TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
# TransactionType.JOINT,
|
||||
# TransactionType.THROW_PRODUCT,
|
||||
]
|
||||
),
|
||||
Transaction.product_id == product_id,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
CONST_TRUE if until is None else Transaction.time <= until,
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
.subquery(trx_subset_name)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
initial_element = select(
|
||||
@@ -139,19 +117,35 @@ def _product_owners_query(
|
||||
).label("product_count"),
|
||||
# How many products left to account for
|
||||
case(
|
||||
# Someone adds the product -> known owner, decrease the number of products left to account for
|
||||
# Someone adds the product -> increase the number of products left to account for
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
|
||||
),
|
||||
# Stock got adjusted upwards -> none owner, decrease the number of products left to account for
|
||||
# Someone buys/joins/throws the product -> decrease the number of products left to account for
|
||||
# (
|
||||
# trx_subset.c.type_.in_(
|
||||
# [
|
||||
# TransactionType.BUY_PRODUCT,
|
||||
# TransactionType.JOINT,
|
||||
# TransactionType.THROW_PRODUCT,
|
||||
# ]
|
||||
# ),
|
||||
# recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
|
||||
# ),
|
||||
# Someone adjusts the stock ->
|
||||
# If adjusted upwards -> products owned by nobody, decrease products left to account for
|
||||
# If adjusted downwards -> products taken away from owners, decrease products left to account for
|
||||
(
|
||||
and_(
|
||||
trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column(),
|
||||
trx_subset.c.product_count > CONST_ZERO,
|
||||
),
|
||||
(trx_subset.c.type_ == TransactionType.ADJUST_STOCK.as_literal_column())
|
||||
and (trx_subset.c.product_count > CONST_ZERO),
|
||||
recursive_cte.c.products_left_to_account_for - trx_subset.c.product_count,
|
||||
),
|
||||
# (
|
||||
# (trx_subset.c.type_ == TransactionType.ADJUST_STOCK)
|
||||
# and (trx_subset.c.product_count < 0),
|
||||
# recursive_cte.c.products_left_to_account_for + trx_subset.c.product_count,
|
||||
# ),
|
||||
else_=recursive_cte.c.products_left_to_account_for,
|
||||
).label("products_left_to_account_for"),
|
||||
)
|
||||
@@ -159,7 +153,6 @@ def _product_owners_query(
|
||||
.where(
|
||||
and_(
|
||||
trx_subset.c.i == recursive_cte.c.i + CONST_ONE,
|
||||
# Base case: stop if we've accounted for all products
|
||||
recursive_cte.c.products_left_to_account_for > CONST_ZERO,
|
||||
)
|
||||
)
|
||||
@@ -174,14 +167,13 @@ class ProductOwnersLogEntry:
|
||||
user: User | None
|
||||
products_left_to_account_for: int
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def product_owners_log(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> list[ProductOwnersLogEntry]:
|
||||
"""
|
||||
Returns a log of the product ownership calculation for the given product.
|
||||
@@ -192,12 +184,13 @@ def product_owners_log(
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_owners_query(
|
||||
product_id=product.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.execute(
|
||||
@@ -235,13 +228,13 @@ def product_owners_log(
|
||||
]
|
||||
|
||||
|
||||
# TODO: add until transaction parameter
|
||||
|
||||
def product_owners(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: datetime | None = None,
|
||||
) -> list[User | None]:
|
||||
"""
|
||||
Returns an ordered list of users owning the given product.
|
||||
@@ -255,9 +248,7 @@ def product_owners(
|
||||
recursive_cte = _product_owners_query(
|
||||
product_id=product.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until,
|
||||
)
|
||||
|
||||
db_result = sql_session.execute(
|
||||
@@ -301,7 +292,7 @@ def product_owners(
|
||||
|
||||
result.extend([None] * none_count)
|
||||
|
||||
# # NOTE: if the last line exceeds the product count, we need to truncate it
|
||||
# # NOTE: if the last line exeeds the product count, we need to truncate it
|
||||
# result.extend([user] * min(user_count, products_left_to_account_for))
|
||||
|
||||
# redistribute the user counts to a list of users
|
||||
|
||||
@@ -6,7 +6,7 @@ from sqlalchemy import (
|
||||
BindParameter,
|
||||
ColumnElement,
|
||||
Integer,
|
||||
bindparam,
|
||||
asc,
|
||||
case,
|
||||
cast,
|
||||
func,
|
||||
@@ -14,110 +14,52 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO
|
||||
from dibbler.models import (
|
||||
LastCacheTransaction,
|
||||
Product,
|
||||
ProductCache,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENT
|
||||
from dibbler.queries.query_helpers import (
|
||||
CONST_NONE,
|
||||
CONST_ONE,
|
||||
CONST_ZERO,
|
||||
until_filter, after_filter,
|
||||
)
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
|
||||
|
||||
def _product_price_query(
|
||||
product_id: int | ColumnElement[int],
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: BindParameter[datetime] | datetime | None = None,
|
||||
until_including: BindParameter[bool] | bool = True,
|
||||
cte_name: str = "rec_cte",
|
||||
trx_subset_name: str = "trx_subset",
|
||||
):
|
||||
"""
|
||||
The inner query for calculating the product price.
|
||||
"""
|
||||
|
||||
if use_cache:
|
||||
print("WARNING: Using cache for product price query is not implemented yet.")
|
||||
|
||||
if isinstance(product_id, int):
|
||||
product_id = BindParameter("product_id", value=product_id)
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until)
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
if isinstance(until_including, bool):
|
||||
until_including = BindParameter("until_including", value=until_including)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
if use_cache:
|
||||
initial_element_fields = (
|
||||
select(
|
||||
Transaction.time.label("time"),
|
||||
Transaction.id.label("transaction_id"),
|
||||
ProductCache.price.label("price"),
|
||||
ProductCache.stock.label("product_count"),
|
||||
)
|
||||
.select_from(ProductCache)
|
||||
.join(
|
||||
LastCacheTransaction,
|
||||
ProductCache.last_cache_transaction_id == LastCacheTransaction.id,
|
||||
)
|
||||
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
|
||||
.where(
|
||||
ProductCache.product_id == product_id,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
)
|
||||
.union(
|
||||
select(
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_ZERO.label("price"),
|
||||
CONST_ZERO.label("product_count"),
|
||||
)
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(CONST_ONE)
|
||||
.offset(CONST_ZERO)
|
||||
.subquery()
|
||||
.alias("initial_element_fields")
|
||||
)
|
||||
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
initial_element_fields.c.time,
|
||||
initial_element_fields.c.transaction_id,
|
||||
initial_element_fields.c.price,
|
||||
initial_element_fields.c.product_count,
|
||||
).select_from(initial_element_fields)
|
||||
else:
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_ZERO.label("price"),
|
||||
CONST_ZERO.label("product_count"),
|
||||
)
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_ZERO.label("price"),
|
||||
CONST_ZERO.label("product_count"),
|
||||
)
|
||||
|
||||
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
|
||||
|
||||
# Subset of transactions that we'll want to iterate over.
|
||||
trx_subset = (
|
||||
select(
|
||||
func.row_number().over(order_by=Transaction.time.asc()).label("i"),
|
||||
func.row_number().over(order_by=asc(Transaction.time)).label("i"),
|
||||
Transaction.id,
|
||||
Transaction.time,
|
||||
Transaction.type_,
|
||||
@@ -134,19 +76,15 @@ def _product_price_query(
|
||||
]
|
||||
),
|
||||
Transaction.product_id == product_id,
|
||||
after_filter(
|
||||
after_time=None,
|
||||
after_transaction_id=recursive_cte.c.transaction_id,
|
||||
after_inclusive=False,
|
||||
),
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
case(
|
||||
(until_including, Transaction.time <= until),
|
||||
else_=Transaction.time < until,
|
||||
)
|
||||
if until is not None
|
||||
else CONST_TRUE,
|
||||
)
|
||||
.order_by(Transaction.time.asc())
|
||||
.subquery(trx_subset_name)
|
||||
.alias("trx_subset")
|
||||
)
|
||||
|
||||
recursive_elements = (
|
||||
@@ -232,13 +170,13 @@ class ProductPriceLogEntry:
|
||||
product_count: int
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def product_price_log(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> list[ProductPriceLogEntry]:
|
||||
"""
|
||||
Calculates the price of a product and returns a log of the price changes.
|
||||
@@ -247,12 +185,13 @@ def product_price_log(
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_price_query(
|
||||
product.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.execute(
|
||||
@@ -285,13 +224,13 @@ def product_price_log(
|
||||
]
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def product_price(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: Transaction | None = None,
|
||||
include_interest: bool = False,
|
||||
) -> int:
|
||||
"""
|
||||
@@ -301,22 +240,13 @@ def product_price(
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _product_price_query(
|
||||
product.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
# TODO: optionally verify subresults:
|
||||
@@ -342,16 +272,12 @@ def product_price(
|
||||
select(Transaction.interest_rate_percent)
|
||||
.where(
|
||||
Transaction.type_ == TransactionType.ADJUST_INTEREST,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
CONST_TRUE if until is None else Transaction.time <= until.time,
|
||||
)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(CONST_ONE)
|
||||
)
|
||||
or DEFAULT_INTEREST_RATE_PERCENT
|
||||
or DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
)
|
||||
result = math.ceil(result * interest_rate / 100)
|
||||
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import (
|
||||
BindParameter,
|
||||
Select,
|
||||
bindparam,
|
||||
case,
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_TRUE
|
||||
from dibbler.models import (
|
||||
Product,
|
||||
Transaction,
|
||||
TransactionType,
|
||||
)
|
||||
from dibbler.queries.query_helpers import until_filter
|
||||
|
||||
|
||||
def _product_stock_query(
|
||||
product_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
) -> Select[Tuple[int]]:
|
||||
until: BindParameter[datetime] | datetime | None = None,
|
||||
) -> Select:
|
||||
"""
|
||||
The inner query for calculating the product stock.
|
||||
"""
|
||||
@@ -36,18 +32,8 @@ def _product_stock_query(
|
||||
if isinstance(product_id, int):
|
||||
product_id = BindParameter("product_id", value=product_id)
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until)
|
||||
|
||||
query = select(
|
||||
func.sum(
|
||||
@@ -74,7 +60,7 @@ def _product_stock_query(
|
||||
),
|
||||
else_=0,
|
||||
)
|
||||
).label("stock")
|
||||
)
|
||||
).where(
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
@@ -86,23 +72,19 @@ def _product_stock_query(
|
||||
]
|
||||
),
|
||||
Transaction.product_id == product_id,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
Transaction.time <= until if until is not None else CONST_TRUE,
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
# TODO: add until transaction parameter
|
||||
|
||||
def product_stock(
|
||||
sql_session: Session,
|
||||
product: Product,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: datetime | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Returns the number of products in stock.
|
||||
@@ -116,9 +98,7 @@ def product_stock(
|
||||
query = _product_stock_query(
|
||||
product_id=product.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until,
|
||||
)
|
||||
|
||||
result = sql_session.scalars(query).one_or_none()
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
from datetime import datetime
|
||||
from typing import TypeVar
|
||||
|
||||
from sqlalchemy import (
|
||||
BindParameter,
|
||||
ColumnExpressionArgument,
|
||||
literal,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import QueryableAttribute
|
||||
|
||||
from dibbler.models import Transaction
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def const(value: T) -> BindParameter[T]:
|
||||
"""
|
||||
Create a constant SQL literal bind parameter.
|
||||
|
||||
This is useful to avoid too many `?` bind parameters in SQL queries,
|
||||
when the input value is known to be safe.
|
||||
"""
|
||||
|
||||
return literal(value, literal_execute=True)
|
||||
|
||||
|
||||
CONST_ZERO: BindParameter[int] = const(0)
|
||||
"""A constant SQL expression `0`. This will render as a literal `0` in SQL queries."""
|
||||
|
||||
CONST_ONE: BindParameter[int] = const(1)
|
||||
"""A constant SQL expression `1`. This will render as a literal `1` in SQL queries."""
|
||||
|
||||
CONST_TRUE: BindParameter[bool] = const(True)
|
||||
"""A constant SQL expression `TRUE`. This will render as a literal `TRUE` in SQL queries."""
|
||||
|
||||
CONST_FALSE: BindParameter[bool] = const(False)
|
||||
"""A constant SQL expression `FALSE`. This will render as a literal `FALSE` in SQL queries."""
|
||||
|
||||
CONST_NONE: BindParameter[None] = const(None)
|
||||
"""A constant SQL expression `NULL`. This will render as a literal `NULL` in SQL queries."""
|
||||
|
||||
|
||||
def until_filter(
|
||||
until_time: BindParameter[datetime] | None = None,
|
||||
until_transaction_id: BindParameter[int] | None = None,
|
||||
until_inclusive: bool = True,
|
||||
transaction_time: QueryableAttribute = Transaction.time,
|
||||
) -> ColumnExpressionArgument[bool]:
|
||||
"""
|
||||
Create a filter condition for transactions up to a given time or transaction.
|
||||
|
||||
Only one of `until_time` or `until_transaction_id` may be specified.
|
||||
"""
|
||||
|
||||
assert not (until_time is not None and until_transaction_id is not None), (
|
||||
"Cannot filter by both until_time and until_transaction_id."
|
||||
)
|
||||
|
||||
match (until_time, until_transaction_id, until_inclusive):
|
||||
case (BindParameter(), None, True):
|
||||
return transaction_time <= until_time
|
||||
case (BindParameter(), None, False):
|
||||
return transaction_time < until_time
|
||||
case (None, BindParameter(), True):
|
||||
return (
|
||||
transaction_time
|
||||
<= select(Transaction.time)
|
||||
.where(Transaction.id == until_transaction_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
case (None, BindParameter(), False):
|
||||
return (
|
||||
transaction_time
|
||||
< select(Transaction.time)
|
||||
.where(Transaction.id == until_transaction_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
return CONST_TRUE
|
||||
|
||||
|
||||
def after_filter(
|
||||
after_time: BindParameter[datetime] | None = None,
|
||||
after_transaction_id: BindParameter[int] | None = None,
|
||||
after_inclusive: bool = True,
|
||||
transaction_time: QueryableAttribute = Transaction.time,
|
||||
) -> ColumnExpressionArgument[bool]:
|
||||
"""
|
||||
Create a filter condition for transactions after a given time or transaction.
|
||||
|
||||
Only one of `after_time` or `after_transaction_id` may be specified.
|
||||
"""
|
||||
|
||||
assert not (after_time is not None and after_transaction_id is not None), (
|
||||
"Cannot filter by both after_time and after_transaction_id."
|
||||
)
|
||||
|
||||
match (after_time, after_transaction_id, after_inclusive):
|
||||
case (BindParameter(), None, True):
|
||||
return transaction_time >= after_time
|
||||
case (BindParameter(), None, False):
|
||||
return transaction_time > after_time
|
||||
case (None, BindParameter(), True):
|
||||
return (
|
||||
transaction_time
|
||||
>= select(Transaction.time)
|
||||
.where(Transaction.id == after_transaction_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
case (None, BindParameter(), False):
|
||||
return (
|
||||
transaction_time
|
||||
> select(Transaction.time)
|
||||
.where(Transaction.id == after_transaction_id)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
return CONST_TRUE
|
||||
@@ -9,9 +9,6 @@ def search_product(
|
||||
sql_session: Session,
|
||||
find_hidden_products=False,
|
||||
) -> Product | list[Product]:
|
||||
if not string:
|
||||
raise ValueError("Search string cannot be empty.")
|
||||
|
||||
exact_match = sql_session.scalars(
|
||||
select(Product).where(
|
||||
or_(
|
||||
|
||||
@@ -8,9 +8,6 @@ def search_user(
|
||||
string: str,
|
||||
sql_session: Session,
|
||||
) -> User | list[User]:
|
||||
if not string:
|
||||
raise ValueError("Search string cannot be empty.")
|
||||
|
||||
string = string.lower()
|
||||
|
||||
exact_match = sql_session.scalars(
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
|
||||
|
||||
def throw_product(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
product: Product,
|
||||
product_count: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if product_count <= 0:
|
||||
raise ValueError("Product count must be positive.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
raise NotImplementedError(
|
||||
"Please don't use this function until relevant calculations have been added to user_balance."
|
||||
)
|
||||
|
||||
transaction = Transaction.throw_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=product_count,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
@@ -1,6 +1,4 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BindParameter, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import (
|
||||
@@ -17,12 +15,12 @@ def transaction_log(
|
||||
sql_session: Session,
|
||||
user: User | None = None,
|
||||
product: Product | None = None,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
after_time: BindParameter[datetime] | datetime | None = None,
|
||||
after_transaction: Transaction | None = None,
|
||||
after_inclusive: bool = True,
|
||||
exclusive_after: bool = False,
|
||||
after_time=None,
|
||||
after_transaction_id: int | None = None,
|
||||
exclusive_before: bool = False,
|
||||
before_time=None,
|
||||
before_transaction_id: int | None = None,
|
||||
transaction_type: list[TransactionType] | None = None,
|
||||
negate_transaction_type_filter: bool = False,
|
||||
limit: int | None = None,
|
||||
@@ -31,101 +29,51 @@ def transaction_log(
|
||||
Retrieve the transaction log, optionally filtered.
|
||||
|
||||
Only one of `user` or `product` may be specified.
|
||||
Only one of `until_time` or `until_transaction_id` may be specified.
|
||||
Only one of `after_time` or `after_transaction_id` may be specified.
|
||||
Only one of `before_time` or `before_transaction_id` may be specified.
|
||||
|
||||
The after and after filters are inclusive by default.
|
||||
The before and after filters are inclusive by default.
|
||||
"""
|
||||
|
||||
if not (user is None or product is None):
|
||||
raise ValueError("Cannot filter by both user and product.")
|
||||
|
||||
if isinstance(user, User):
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
user_id = BindParameter("user_id", value=user.id)
|
||||
else:
|
||||
user_id = None
|
||||
if user is not None and user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if isinstance(product, Product):
|
||||
if product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
product_id = BindParameter("product_id", value=product.id)
|
||||
else:
|
||||
product_id = None
|
||||
if product is not None and product.id is None:
|
||||
raise ValueError("Product must be persisted in the database.")
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = BindParameter("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
if not (after_time is None or after_transaction is None):
|
||||
raise ValueError("Cannot filter by both after_time and after_transaction_id.")
|
||||
|
||||
if isinstance(after_time, datetime):
|
||||
after_time = BindParameter("after_time", value=after_time)
|
||||
|
||||
if isinstance(after_transaction, Transaction):
|
||||
if after_transaction.id is None:
|
||||
raise ValueError("after_transaction must be persisted in the database.")
|
||||
after_transaction_id = BindParameter("after_transaction_id", value=after_transaction.id)
|
||||
else:
|
||||
after_transaction_id = None
|
||||
|
||||
if after_time is not None and until_time is not None:
|
||||
assert isinstance(after_time.value, datetime)
|
||||
assert isinstance(until_time.value, datetime)
|
||||
|
||||
if after_time.value > until_time.value:
|
||||
raise ValueError("after_time cannot be after until_time.")
|
||||
|
||||
if after_transaction is not None and until_transaction is not None:
|
||||
assert after_transaction.time is not None
|
||||
assert until_transaction.time is not None
|
||||
|
||||
if after_transaction.time > until_transaction.time:
|
||||
raise ValueError("after_transaction cannot be after until_transaction.")
|
||||
|
||||
if limit is not None and limit <= 0:
|
||||
raise ValueError("Limit must be positive.")
|
||||
if not (after_time is None or after_transaction_id is None):
|
||||
raise ValueError("Cannot filter by both from_time and from_transaction_id.")
|
||||
|
||||
query = select(Transaction)
|
||||
if user is not None:
|
||||
query = query.where(Transaction.user_id == user_id)
|
||||
query = query.where(Transaction.user_id == user.id)
|
||||
if product is not None:
|
||||
query = query.where(Transaction.product_id == product_id)
|
||||
query = query.where(Transaction.product_id == product.id)
|
||||
|
||||
match (until_time, until_transaction_id, until_inclusive):
|
||||
case (BindParameter(), None, True):
|
||||
query = query.where(Transaction.time <= until_time)
|
||||
case (BindParameter(), None, False):
|
||||
query = query.where(Transaction.time < until_time)
|
||||
case (None, BindParameter(), True):
|
||||
query = query.where(Transaction.id <= until_transaction_id)
|
||||
case (None, BindParameter(), False):
|
||||
query = query.where(Transaction.id < until_transaction_id)
|
||||
case _:
|
||||
pass
|
||||
|
||||
match (after_time, after_transaction_id, after_inclusive):
|
||||
case (BindParameter(), None, True):
|
||||
query = query.where(Transaction.time >= after_time)
|
||||
case (BindParameter(), None, False):
|
||||
if after_time is not None:
|
||||
if exclusive_after:
|
||||
query = query.where(Transaction.time > after_time)
|
||||
case (None, BindParameter(), True):
|
||||
query = query.where(Transaction.id >= after_transaction_id)
|
||||
case (None, BindParameter(), False):
|
||||
else:
|
||||
query = query.where(Transaction.time >= after_time)
|
||||
if after_transaction_id is not None:
|
||||
if exclusive_after:
|
||||
query = query.where(Transaction.id > after_transaction_id)
|
||||
case _:
|
||||
pass
|
||||
else:
|
||||
query = query.where(Transaction.id >= after_transaction_id)
|
||||
|
||||
if before_time is not None:
|
||||
if exclusive_before:
|
||||
query = query.where(Transaction.time < before_time)
|
||||
else:
|
||||
query = query.where(Transaction.time <= before_time)
|
||||
if before_transaction_id is not None:
|
||||
if exclusive_before:
|
||||
query = query.where(Transaction.id < before_transaction_id)
|
||||
else:
|
||||
query = query.where(Transaction.id <= before_transaction_id)
|
||||
|
||||
if transaction_type is not None:
|
||||
if negate_transaction_type_filter:
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
|
||||
|
||||
def transfer(
|
||||
sql_session: Session,
|
||||
from_user: User,
|
||||
to_user: User,
|
||||
amount: int,
|
||||
time: datetime | None = None,
|
||||
message: str | None = None,
|
||||
) -> Transaction:
|
||||
if from_user.id is None:
|
||||
raise ValueError("From user must be persisted in the database.")
|
||||
|
||||
if to_user.id is None:
|
||||
raise ValueError("To user must be persisted in the database.")
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount must be positive.")
|
||||
|
||||
# TODO: verify time is not behind last transaction's time
|
||||
|
||||
transaction = Transaction.transfer(
|
||||
user_id=from_user.id,
|
||||
transfer_user_id=to_user.id,
|
||||
amount=amount,
|
||||
time=time,
|
||||
message=message,
|
||||
)
|
||||
|
||||
sql_session.add(transaction)
|
||||
sql_session.commit()
|
||||
|
||||
return transaction
|
||||
@@ -1,118 +0,0 @@
|
||||
from sqlalchemy import insert, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import LastCacheTransaction, ProductCache, Transaction, UserCache
|
||||
from dibbler.queries.affected_products import affected_products
|
||||
from dibbler.queries.affected_users import affected_users
|
||||
from dibbler.queries.product_price import product_price
|
||||
from dibbler.queries.product_stock import product_stock
|
||||
from dibbler.queries.user_balance import user_balance
|
||||
|
||||
|
||||
def update_cache(
|
||||
sql_session: Session,
|
||||
use_cache: bool = True,
|
||||
) -> None:
|
||||
"""
|
||||
Update the cache used for searching products.
|
||||
|
||||
If `use_cache` is False, the cache will be rebuilt from scratch.
|
||||
"""
|
||||
|
||||
last_transaction = sql_session.scalars(
|
||||
select(Transaction).order_by(Transaction.time.desc()).limit(1)
|
||||
).one_or_none()
|
||||
|
||||
print(last_transaction)
|
||||
|
||||
if last_transaction is None:
|
||||
# No transactions exist, nothing to update
|
||||
return
|
||||
|
||||
if use_cache:
|
||||
last_cache_transaction = sql_session.scalars(
|
||||
select(LastCacheTransaction)
|
||||
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
|
||||
.order_by(Transaction.time.desc())
|
||||
.limit(1)
|
||||
).one_or_none()
|
||||
if last_cache_transaction is not None:
|
||||
last_cache_transaction = last_cache_transaction.transaction
|
||||
else:
|
||||
last_cache_transaction = None
|
||||
|
||||
if last_cache_transaction is not None and last_cache_transaction.id == last_transaction.id:
|
||||
# Cache is already up to date
|
||||
return
|
||||
|
||||
users = affected_users(
|
||||
sql_session,
|
||||
after_transaction=last_cache_transaction,
|
||||
after_inclusive=False,
|
||||
until_transaction=last_transaction,
|
||||
)
|
||||
products = affected_products(
|
||||
sql_session,
|
||||
after_transaction=last_cache_transaction,
|
||||
after_inclusive=False,
|
||||
until_transaction=last_transaction,
|
||||
)
|
||||
|
||||
user_balances = {}
|
||||
for user in users:
|
||||
x = user_balance(
|
||||
sql_session,
|
||||
user,
|
||||
use_cache=use_cache,
|
||||
until_transaction=last_transaction,
|
||||
)
|
||||
user_balances[user.id] = x
|
||||
|
||||
product_stocks = {}
|
||||
product_prices = {}
|
||||
for product in products:
|
||||
product_stocks[product.id] = product_stock(
|
||||
sql_session,
|
||||
product,
|
||||
use_cache=use_cache,
|
||||
until_transaction=last_transaction,
|
||||
)
|
||||
product_prices[product.id] = product_price(
|
||||
sql_session,
|
||||
product,
|
||||
use_cache=use_cache,
|
||||
until_transaction=last_transaction,
|
||||
)
|
||||
|
||||
next_cache_transaction = LastCacheTransaction(transaction_id=last_transaction.id)
|
||||
sql_session.add(next_cache_transaction)
|
||||
sql_session.flush()
|
||||
|
||||
if not len(users) == 0:
|
||||
sql_session.execute(
|
||||
insert(UserCache),
|
||||
[
|
||||
{
|
||||
"user_id": user.id,
|
||||
"balance": user_balances[user.id],
|
||||
"last_cache_transaction_id": next_cache_transaction.id,
|
||||
}
|
||||
for user in users
|
||||
],
|
||||
)
|
||||
|
||||
if not len(products) == 0:
|
||||
sql_session.execute(
|
||||
insert(ProductCache),
|
||||
[
|
||||
{
|
||||
"product_id": product.id,
|
||||
"stock": product_stocks[product.id],
|
||||
"price": product_prices[product.id],
|
||||
"last_cache_transaction_id": next_cache_transaction.id,
|
||||
}
|
||||
for product in products
|
||||
],
|
||||
)
|
||||
|
||||
sql_session.commit()
|
||||
@@ -1,15 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Tuple
|
||||
|
||||
from sqlalchemy import (
|
||||
CTE,
|
||||
BindParameter,
|
||||
Float,
|
||||
Integer,
|
||||
Select,
|
||||
and_,
|
||||
bindparam,
|
||||
case,
|
||||
cast,
|
||||
column,
|
||||
@@ -17,243 +14,28 @@ from sqlalchemy import (
|
||||
or_,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
from sqlalchemy.sql.elements import KeyedColumnElement
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.lib.query_helpers import CONST_NONE, CONST_ONE, CONST_TRUE, CONST_ZERO, const
|
||||
from dibbler.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_INTEREST_RATE_PERCENT,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
DEFAULT_INTEREST_RATE_PERCENTAGE,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
)
|
||||
from dibbler.queries.product_price import _product_price_query
|
||||
from dibbler.queries.query_helpers import (
|
||||
CONST_NONE,
|
||||
CONST_ONE,
|
||||
CONST_ZERO,
|
||||
const,
|
||||
until_filter,
|
||||
)
|
||||
|
||||
|
||||
def _joint_transaction_query(
|
||||
user_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
) -> Select[Tuple[int, int, int]]:
|
||||
"""
|
||||
The inner query for getting joint transactions relevant to a user.
|
||||
|
||||
This scans for JOINT_BUY_PRODUCT transactions made by the user,
|
||||
then finds the corresponding JOINT transactions, and counts how many "shares"
|
||||
of the joint transaction the user has, as well as the total number of shares.
|
||||
"""
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
# First, select all joint buy product transactions for the given user
|
||||
# sub_joint_transaction = aliased(Transaction, name="right_trx")
|
||||
sub_joint_transaction = (
|
||||
select(Transaction.joint_transaction_id.distinct().label("joint_transaction_id"))
|
||||
.where(
|
||||
Transaction.type_ == TransactionType.JOINT_BUY_PRODUCT.as_literal_column(),
|
||||
Transaction.user_id == user_id,
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
transaction_time=Transaction.time,
|
||||
),
|
||||
)
|
||||
.subquery("sub_joint_transaction")
|
||||
)
|
||||
|
||||
# Join those with their main joint transaction
|
||||
# (just use Transaction)
|
||||
|
||||
# Then, count how many users are involved in each joint transaction
|
||||
joint_transaction_count = aliased(Transaction, name="count_trx")
|
||||
|
||||
joint_transaction = (
|
||||
select(
|
||||
Transaction.id,
|
||||
# Shares the user has in the transaction,
|
||||
func.sum(
|
||||
case(
|
||||
(joint_transaction_count.user_id == user_id, CONST_ONE),
|
||||
else_=CONST_ZERO,
|
||||
)
|
||||
).label("user_shares"),
|
||||
# The total number of shares in the transaction,
|
||||
func.count(joint_transaction_count.id).label("user_count"),
|
||||
)
|
||||
.select_from(sub_joint_transaction)
|
||||
.join(
|
||||
Transaction,
|
||||
onclause=Transaction.id == sub_joint_transaction.c.joint_transaction_id,
|
||||
)
|
||||
.join(
|
||||
joint_transaction_count,
|
||||
onclause=joint_transaction_count.joint_transaction_id == Transaction.id,
|
||||
)
|
||||
.group_by(joint_transaction_count.joint_transaction_id)
|
||||
)
|
||||
|
||||
return joint_transaction
|
||||
|
||||
|
||||
def _non_joint_transaction_query(
|
||||
user_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
) -> Select[Tuple[int, None, None]]:
|
||||
"""
|
||||
The inner query for getting non-joint transactions relevant to a user.
|
||||
"""
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
query = select(
|
||||
Transaction.id,
|
||||
CONST_NONE.label("user_shares"),
|
||||
CONST_NONE.label("user_count"),
|
||||
).where(
|
||||
or_(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_BALANCE.as_literal_column(),
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.TRANSFER.as_literal_column(),
|
||||
]
|
||||
),
|
||||
),
|
||||
and_(
|
||||
Transaction.type_ == TransactionType.TRANSFER.as_literal_column(),
|
||||
Transaction.transfer_user_id == user_id,
|
||||
),
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.THROW_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_INTEREST.as_literal_column(),
|
||||
TransactionType.ADJUST_PENALTY.as_literal_column(),
|
||||
]
|
||||
),
|
||||
),
|
||||
until_filter(
|
||||
until_time=until_time,
|
||||
until_transaction_id=until_transaction_id,
|
||||
until_inclusive=until_inclusive,
|
||||
),
|
||||
)
|
||||
|
||||
return query
|
||||
|
||||
|
||||
def _product_cost_expression(
|
||||
product_count_column: KeyedColumnElement[int],
|
||||
product_id_column: KeyedColumnElement[int],
|
||||
interest_rate_percent_column: KeyedColumnElement[int],
|
||||
user_balance_column: KeyedColumnElement[int],
|
||||
penalty_threshold_column: KeyedColumnElement[int],
|
||||
penalty_multiplier_percent_column: KeyedColumnElement[int],
|
||||
joint_user_shares_column: KeyedColumnElement[int],
|
||||
joint_user_count_column: KeyedColumnElement[int],
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
cte_name: str = "product_price_cte",
|
||||
trx_subset_name: str = "product_price_trx_subset",
|
||||
):
|
||||
# TODO: This can get quite expensive real quick, so we should do some caching of the
|
||||
# product prices somehow.
|
||||
expression = (
|
||||
select(
|
||||
cast(
|
||||
func.ceil(
|
||||
# Base price
|
||||
(
|
||||
cast(
|
||||
column("price") * product_count_column * joint_user_shares_column,
|
||||
Float,
|
||||
)
|
||||
/ joint_user_count_column
|
||||
)
|
||||
# Interest
|
||||
+ (
|
||||
cast(
|
||||
column("price") * product_count_column * joint_user_shares_column,
|
||||
Float,
|
||||
)
|
||||
/ joint_user_count_column
|
||||
* cast(interest_rate_percent_column - const(100), Float)
|
||||
/ const(100.0)
|
||||
)
|
||||
# Penalty
|
||||
+ (
|
||||
(
|
||||
cast(
|
||||
column("price") * product_count_column * joint_user_shares_column,
|
||||
Float,
|
||||
)
|
||||
/ joint_user_count_column
|
||||
)
|
||||
* cast(penalty_multiplier_percent_column - const(100), Float)
|
||||
/ const(100.0)
|
||||
* cast(user_balance_column < penalty_threshold_column, Integer)
|
||||
)
|
||||
),
|
||||
Integer,
|
||||
)
|
||||
)
|
||||
.select_from(
|
||||
_product_price_query(
|
||||
product_id_column,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
cte_name=cte_name,
|
||||
trx_subset_name=trx_subset_name,
|
||||
)
|
||||
)
|
||||
.order_by(column("i").desc())
|
||||
.limit(CONST_ONE)
|
||||
.scalar_subquery()
|
||||
)
|
||||
|
||||
return expression
|
||||
|
||||
|
||||
def _user_balance_query(
|
||||
user_id: BindParameter[int] | int,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: BindParameter[datetime] | BindParameter[None] | datetime | None = None,
|
||||
until_including: BindParameter[bool] | bool = True,
|
||||
cte_name: str = "rec_cte",
|
||||
trx_subset_name: str = "trx_subset",
|
||||
) -> CTE:
|
||||
"""
|
||||
The inner query for calculating the user's balance.
|
||||
@@ -265,44 +47,30 @@ def _user_balance_query(
|
||||
if isinstance(user_id, int):
|
||||
user_id = BindParameter("user_id", value=user_id)
|
||||
|
||||
if isinstance(until, datetime):
|
||||
until = BindParameter("until", value=until, type_=datetime)
|
||||
|
||||
if isinstance(until_including, bool):
|
||||
until_including = BindParameter("until_including", value=until_including, type_=bool)
|
||||
|
||||
initial_element = select(
|
||||
CONST_ZERO.label("i"),
|
||||
CONST_ZERO.label("time"),
|
||||
CONST_NONE.label("transaction_id"),
|
||||
CONST_ZERO.label("balance"),
|
||||
const(DEFAULT_INTEREST_RATE_PERCENT).label("interest_rate_percent"),
|
||||
const(DEFAULT_INTEREST_RATE_PERCENTAGE).label("interest_rate_percent"),
|
||||
const(DEFAULT_PENALTY_THRESHOLD).label("penalty_threshold"),
|
||||
const(DEFAULT_PENALTY_MULTIPLIER_PERCENT).label("penalty_multiplier_percent"),
|
||||
const(DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE).label("penalty_multiplier_percent"),
|
||||
)
|
||||
|
||||
recursive_cte = initial_element.cte(name=cte_name, recursive=True)
|
||||
|
||||
trx_subset_subset = (
|
||||
_non_joint_transaction_query(
|
||||
user_id=user_id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
)
|
||||
.union_all(
|
||||
_joint_transaction_query(
|
||||
user_id=user_id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
)
|
||||
)
|
||||
.subquery(f"{trx_subset_name}_subset")
|
||||
)
|
||||
|
||||
# Subset of transactions that we'll want to iterate over.
|
||||
trx_subset = (
|
||||
select(
|
||||
func.row_number().over(order_by=Transaction.time.asc()).label("i"),
|
||||
Transaction.id,
|
||||
Transaction.amount,
|
||||
Transaction.id,
|
||||
Transaction.interest_rate_percent,
|
||||
Transaction.penalty_multiplier_percent,
|
||||
Transaction.penalty_threshold,
|
||||
@@ -311,16 +79,44 @@ def _user_balance_query(
|
||||
Transaction.time,
|
||||
Transaction.transfer_user_id,
|
||||
Transaction.type_,
|
||||
trx_subset_subset.c.user_shares,
|
||||
trx_subset_subset.c.user_count,
|
||||
)
|
||||
.select_from(trx_subset_subset)
|
||||
.join(
|
||||
Transaction,
|
||||
onclause=Transaction.id == trx_subset_subset.c.id,
|
||||
.where(
|
||||
or_(
|
||||
and_(
|
||||
Transaction.user_id == user_id,
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.ADD_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_BALANCE.as_literal_column(),
|
||||
TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
TransactionType.TRANSFER.as_literal_column(),
|
||||
# TODO: join this with the JOINT transactions, and determine
|
||||
# how much the current user paid for the product.
|
||||
TransactionType.JOINT_BUY_PRODUCT.as_literal_column(),
|
||||
]
|
||||
),
|
||||
),
|
||||
and_(
|
||||
Transaction.type_ == TransactionType.TRANSFER.as_literal_column(),
|
||||
Transaction.transfer_user_id == user_id,
|
||||
),
|
||||
Transaction.type_.in_(
|
||||
[
|
||||
TransactionType.THROW_PRODUCT.as_literal_column(),
|
||||
TransactionType.ADJUST_INTEREST.as_literal_column(),
|
||||
TransactionType.ADJUST_PENALTY.as_literal_column(),
|
||||
]
|
||||
),
|
||||
),
|
||||
case(
|
||||
(until_including, Transaction.time <= until),
|
||||
else_=Transaction.time < until,
|
||||
)
|
||||
if until is not None
|
||||
else CONST_TRUE,
|
||||
)
|
||||
.order_by(Transaction.time.asc())
|
||||
.subquery(trx_subset_name)
|
||||
.alias("trx_subset")
|
||||
)
|
||||
|
||||
recursive_elements = (
|
||||
@@ -343,43 +139,49 @@ def _user_balance_query(
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.BUY_PRODUCT.as_literal_column(),
|
||||
recursive_cte.c.balance
|
||||
- _product_cost_expression(
|
||||
product_count_column=trx_subset.c.product_count,
|
||||
product_id_column=trx_subset.c.product_id,
|
||||
interest_rate_percent_column=recursive_cte.c.interest_rate_percent,
|
||||
user_balance_column=recursive_cte.c.balance,
|
||||
penalty_threshold_column=recursive_cte.c.penalty_threshold,
|
||||
penalty_multiplier_percent_column=recursive_cte.c.penalty_multiplier_percent,
|
||||
joint_user_shares_column=CONST_ONE,
|
||||
joint_user_count_column=CONST_ONE,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
cte_name=f"{cte_name}_price",
|
||||
trx_subset_name=f"{trx_subset_name}_price",
|
||||
).label("product_cost"),
|
||||
),
|
||||
# Joint transaction -> balance decreases proportionally
|
||||
(
|
||||
trx_subset.c.type_ == TransactionType.JOINT.as_literal_column(),
|
||||
recursive_cte.c.balance
|
||||
- _product_cost_expression(
|
||||
product_count_column=trx_subset.c.product_count,
|
||||
product_id_column=trx_subset.c.product_id,
|
||||
interest_rate_percent_column=recursive_cte.c.interest_rate_percent,
|
||||
user_balance_column=recursive_cte.c.balance,
|
||||
penalty_threshold_column=recursive_cte.c.penalty_threshold,
|
||||
penalty_multiplier_percent_column=recursive_cte.c.penalty_multiplier_percent,
|
||||
joint_user_shares_column=trx_subset.c.user_shares,
|
||||
joint_user_count_column=trx_subset.c.user_count,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
cte_name=f"{cte_name}_joint_price",
|
||||
trx_subset_name=f"{trx_subset_name}_joint_price",
|
||||
).label("joint_product_cost"),
|
||||
- (
|
||||
trx_subset.c.product_count
|
||||
# Price of a single product, accounted for penalties and interest.
|
||||
* cast(
|
||||
func.ceil(
|
||||
# TODO: This can get quite expensive real quick, so we should do some caching of the
|
||||
# product prices somehow.
|
||||
# Base price
|
||||
(
|
||||
# FIXME: this always returns 0 for some reason...
|
||||
select(cast(column("price"), Float))
|
||||
.select_from(
|
||||
_product_price_query(
|
||||
trx_subset.c.product_id,
|
||||
use_cache=use_cache,
|
||||
until=trx_subset.c.time,
|
||||
until_including=False,
|
||||
cte_name="product_price_cte",
|
||||
)
|
||||
)
|
||||
.order_by(column("i").desc())
|
||||
.limit(CONST_ONE)
|
||||
).scalar_subquery()
|
||||
# TODO: should interest be applied before or after the penalty multiplier?
|
||||
# at the moment of writing, after sound right, but maybe ask someone?
|
||||
# Interest
|
||||
* (cast(recursive_cte.c.interest_rate_percent, Float) / const(100))
|
||||
# TODO: these should be added together, not multiplied, see specification
|
||||
# Penalty
|
||||
* case(
|
||||
(
|
||||
recursive_cte.c.balance < recursive_cte.c.penalty_threshold,
|
||||
(
|
||||
cast(recursive_cte.c.penalty_multiplier_percent, Float)
|
||||
/ const(100)
|
||||
),
|
||||
),
|
||||
else_=const(1.0),
|
||||
)
|
||||
),
|
||||
Integer,
|
||||
)
|
||||
),
|
||||
),
|
||||
# Transfers money to self -> balance increases
|
||||
(
|
||||
@@ -398,7 +200,8 @@ def _user_balance_query(
|
||||
recursive_cte.c.balance - trx_subset.c.amount,
|
||||
),
|
||||
# Throws a product -> if the user is considered to have bought it, balance increases
|
||||
# TODO: # (
|
||||
# TODO:
|
||||
# (
|
||||
# trx_subset.c.type_ == TransactionType.THROW_PRODUCT,
|
||||
# recursive_cte.c.balance + trx_subset.c.amount,
|
||||
# ),
|
||||
@@ -452,16 +255,17 @@ class UserBalanceLogEntry:
|
||||
Returns whether this exact transaction is penalized.
|
||||
"""
|
||||
|
||||
raise NotImplementedError("is_penalized is not implemented yet.")
|
||||
return False
|
||||
|
||||
# return self.transaction.type_ == TransactionType.BUY_PRODUCT and prev?
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def user_balance_log(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> list[UserBalanceLogEntry]:
|
||||
"""
|
||||
Returns a log of the user's balance over time, including interest and penalty adjustments.
|
||||
@@ -472,18 +276,13 @@ def user_balance_log(
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
if until is not None and until.id is None:
|
||||
raise ValueError("'until' transaction must be persisted in the database.")
|
||||
|
||||
recursive_cte = _user_balance_query(
|
||||
user.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.execute(
|
||||
@@ -520,13 +319,13 @@ def user_balance_log(
|
||||
]
|
||||
|
||||
|
||||
# TODO: add until datetime parameter
|
||||
|
||||
def user_balance(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
until: Transaction | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Calculates the balance of a user.
|
||||
@@ -537,18 +336,10 @@ def user_balance(
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
recursive_cte = _user_balance_query(
|
||||
user.id,
|
||||
use_cache=use_cache,
|
||||
until_time=until_time,
|
||||
until_transaction=until_transaction,
|
||||
until_inclusive=until_inclusive,
|
||||
until=until.time if until else None,
|
||||
)
|
||||
|
||||
result = sql_session.scalar(
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BindParameter, bindparam
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
|
||||
# NOTE: This absolutely needs a cache, else we can't stop recursing until we know all owners for all products...
|
||||
# This absoulutely needs a cache, else we can't stop recursing until we know all owners for all products...
|
||||
#
|
||||
# Since we know that the non-owned products will not get renowned by the user by other means,
|
||||
# we can just check for ownership on the products that have an ADD_PRODUCT transaction for the user.
|
||||
@@ -15,34 +8,3 @@ from dibbler.models import Product, Transaction, User
|
||||
# but we still need to check if the user passes out of ownership for the item, without needing to check past
|
||||
# the cache time. Maybe we also need to store the queue number(s) per user/product combo in the cache? What if
|
||||
# a user has products multiple places in the queue, interleaved with other users?
|
||||
|
||||
|
||||
def user_products(
|
||||
sql_session: Session,
|
||||
user: User,
|
||||
use_cache: bool = True,
|
||||
until_time: BindParameter[datetime] | datetime | None = None,
|
||||
until_transaction: Transaction | None = None,
|
||||
until_inclusive: bool = True,
|
||||
) -> list[tuple[Product, int]]:
|
||||
"""
|
||||
Returns the list of products owned by the user, along with how many of each product they own.
|
||||
"""
|
||||
|
||||
if user.id is None:
|
||||
raise ValueError("User must be persisted in the database.")
|
||||
|
||||
if not (until_time is None or until_transaction is None):
|
||||
raise ValueError("Cannot filter by both until_time and until_transaction.")
|
||||
|
||||
if isinstance(until_time, datetime):
|
||||
until_time = BindParameter("until_time", value=until_time)
|
||||
|
||||
if isinstance(until_transaction, Transaction):
|
||||
if until_transaction.id is None:
|
||||
raise ValueError("until_transaction must be persisted in the database.")
|
||||
until_transaction_id = bindparam("until_transaction_id", value=until_transaction.id)
|
||||
else:
|
||||
until_transaction_id = None
|
||||
|
||||
raise NotImplementedError("Not implemented yet, needs caching system first.")
|
||||
|
||||
1
dibbler/subcommands/repopulate_cache.py
Normal file
1
dibbler/subcommands/repopulate_cache.py
Normal file
@@ -0,0 +1 @@
|
||||
# TODO: implement me
|
||||
@@ -1,4 +1,4 @@
|
||||
# Dibbler economy spec v1
|
||||
# Economics
|
||||
|
||||
This document provides an overview of how dibbler counts and calculates its running event log.
|
||||
|
||||
@@ -8,26 +8,20 @@ It is a sort of semi-formal specification for how dibbler's economy is intended
|
||||
|
||||
- All calculations involving money are done in whole numbers (integers). There are no fractional krs.
|
||||
- All rounding is done by rounding up to the nearest integer, in favor of the system economy - not the users.
|
||||
- All rounding is done as late as possible in calculations, to avoid rounding errors accumulating.
|
||||
- The system allows negative stock counts, but acts a bit weirdly and potentially unfairly when that happens.
|
||||
The system should generally warn you about this, and recommend recounting the stock whenever it happens.
|
||||
- Throughout the document, the penalty multiplier and interest rate are expressed as percentages in int (e.g. `penalty_multiplier = 150` means the prices should be multiplied by `1.5`, and `interest_rate = 120` means the prices should be multiplied by `1.2`).
|
||||
|
||||
## Adding products - product stock and product price
|
||||
|
||||
This section covers what happens to the stock count and price of a product when a user adds more of that product to the system.
|
||||
|
||||
### Calculating the total value of products added
|
||||
|
||||
When a user adds a product, the resulting product price is averaged over the new products and the existing products. However, the new product price will become an integer. To avoid the economy going downwards, we round up the price after doing the averaging - i.e. in favor of the system, not the users.
|
||||
|
||||
### When the product count is `0` before adding.
|
||||
|
||||
When the product count is `0`, adding more of that product sets the product count to the amount added, and the product price will be set to the price of all products added divided by the number of products added, rounded up to the nearest integer.
|
||||
|
||||
```python
|
||||
new_product_count: int = products_added
|
||||
new_product_price: int = math.ceil(total_value_of_products_added / products_added)
|
||||
new_product_count = products_added
|
||||
new_product_price = math.ceil(total_value_of_products_added / products_added)
|
||||
```
|
||||
|
||||
### When the product count is greater than `0` before adding.
|
||||
@@ -35,8 +29,8 @@ new_product_price: int = math.ceil(total_value_of_products_added / products_adde
|
||||
When the product count is greater than `0`, adding more of that product increases the product count by the amount added, and the product price will be recalculated as the total value of all existing products plus the total value of all newly added products, divided by the new total product count, rounded up to the nearest integer.
|
||||
|
||||
```python
|
||||
new_product_count: int = product_count + products_added
|
||||
new_product_price: int = math.ceil((product_price * product_count + total_value_of_new_products_added) / new_product_count)
|
||||
new_product_count = product_count + products_added
|
||||
new_product_price = math.ceil((total_value_of_existing_products + total_value_of_products_added) / new_product_count)
|
||||
```
|
||||
|
||||
### When the product count is less than `0` before adding.
|
||||
@@ -50,11 +44,11 @@ When the product count is less than `0`, adding more of that product increases t
|
||||
> [!WARN]
|
||||
> Note that this means that if you add products to a negative stock and the stock is still negative,
|
||||
> the product price will be completely recalculated the next time someone adds the same product.
|
||||
> There will also be a noticeable effect if the stock goes from negative to positive.
|
||||
> There will also be a noticable effect if the stock goes from negative to positive.
|
||||
|
||||
```python
|
||||
new_product_count: int = product_count + products_added
|
||||
new_product_price: int = math.ceil(((product_price * max(product_count, 0)) + (total_value_of_new_products_added)) / new_product_count)
|
||||
new_product_count = product_count + products_added
|
||||
new_product_price = math.ceil(((product_price * math.max(product_count, 0)) + (total_value_of_products_added)) / new_product_count)
|
||||
```
|
||||
|
||||
### A note about adding `0` items
|
||||
@@ -69,7 +63,7 @@ If a user attempts to add `0` items of a product, the system will not change the
|
||||
When the product count is positive and a user buys an amount less than or equal to the current stock count, the product stock count will be decreased by the amount bought.
|
||||
|
||||
```python
|
||||
new_product_count: int = product_count - products_bought
|
||||
new_product_count = product_count - products_bought
|
||||
```
|
||||
|
||||
### When the product count is positive or `0` and you buy more than there are in stock
|
||||
@@ -77,7 +71,7 @@ new_product_count: int = product_count - products_bought
|
||||
When the product count is positive and a user buys an amount greater than the current stock count, the product stock count will be decreased by the amount bought, resulting in a negative stock count.
|
||||
|
||||
```python
|
||||
new_product_count: int = product_count - products_bought
|
||||
new_product_count = product_count - products_bought
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
@@ -88,7 +82,7 @@ new_product_count: int = product_count - products_bought
|
||||
When the product count is negative, buying more of that product will further decrease the product stock count.
|
||||
|
||||
```python
|
||||
new_product_count: int = product_count - products_bought
|
||||
new_product_count = product_count - products_bought
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
@@ -109,10 +103,10 @@ If a user attempts to buy `0` items of a product, the system will not change the
|
||||
|
||||
We have had some issues with the economy going in the negative, most likely due to users throwing away products gone bad. When the economy goes negative, we end up in a situation where users have money but there aren't really any products to buy, because the users don't have the incentive to add products back into the system to gain more balance.
|
||||
|
||||
To readjust the economy over time, there is an interest rate that will increase the amount you pay for each product by a certain percentage (the interest rate). This percentage can be adjusted by administrators when they see that the economy needs fixing. By default, the interest rate is set to `100%` (i.e., you don't pay anything extra).
|
||||
To readjust the economy over time, there is an interest rate that will increase the amount you pay for each product by a certain percentage (the interest rate). This percentage can be adjusted by administrators when they see that the economy needs fixing. By default, the interest rate is set to `0%`.
|
||||
|
||||
> [!NOTE]
|
||||
> You can not go below `100%` interest rate.
|
||||
> You can not go below `0%` interest rate.
|
||||
|
||||
### What is penalty, and why do we need it
|
||||
|
||||
@@ -133,9 +127,7 @@ You gain balance equal to the total value of the products you add.
|
||||
Note that this might be separate from the per-product cost of the products after you add them, due to rounding and price recalculation.
|
||||
|
||||
```python
|
||||
new_user_balance: int = user_balance + total_value_of_products_added
|
||||
|
||||
assert total_value_of_new_products_added >= product_price * products_added
|
||||
new_user_balance = user_balance + total_value_of_products_added
|
||||
```
|
||||
|
||||
### When your existing balance is below the penalty threshold
|
||||
@@ -150,7 +142,7 @@ This case is the same as above.
|
||||
You pay the normal product price for the products you buy, plus any interest.
|
||||
|
||||
```python
|
||||
new_user_balance: int = user_balance - math.ceil(products_bought * product_price * (interest_rate / 100))
|
||||
new_user_balance = user_balance - (products_bought * product_price * (1 + interest_rate))
|
||||
```
|
||||
|
||||
Note that the system performs a transaction for every product kind, so if you buy multiple different products in one go, the rounding is done per product kind.
|
||||
@@ -162,67 +154,34 @@ You pay the penalized product price for the products you buy, plus any interest.
|
||||
The interest and penalty are calculated separately before they are added together, *not* multiplied together.
|
||||
|
||||
```python
|
||||
base_cost: float = product_price * products_bought
|
||||
penalty: float = (base_cost * (penalty_multiplier / 100)) - base_cost
|
||||
interest: float = (base_cost * (interest_rate / 100)) - base_cost
|
||||
new_user_balance: int = user_balance - math.ceil(base_cost + penalty + interest)
|
||||
penalty = ((product_price * penalty_multiplier) - product_price)
|
||||
interest = (product_price * interest_rate)
|
||||
new_user_balance = user_balance - (products_bought * (product_price + penalty + interest))
|
||||
```
|
||||
|
||||
### When your balance is above the penalty threshold before buying, but the purchase pushes you below the threshold
|
||||
|
||||
When your balance is above the penalty threshold before buying, but the purchase pushes you below the threshold, the system not apply any penalty for the purchase. The entire purchase is done at the normal product price plus any interest.
|
||||
TODO:
|
||||
|
||||
```python
|
||||
new_user_balance: int = user_balance - math.ceil(products_bought * product_price * (interest_rate / 100))
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> In the case where you are performing multiple transactions at once, the system should try its best to order the purchases in a way that minimizes the amount of penalties you need to pay.
|
||||
|
||||
### Joint purchases, when all users are above the penalty threshold and stays above the threshold
|
||||
|
||||
When making joint purchases (multiple users buying products together), and all users are above the penalty threshold before and after the purchase, the total cost (including interest) will be split equally between all users. The price will be rounded up for each user after splitting the bill.
|
||||
TODO: how does rounding work here, does one user pay more than the other?
|
||||
|
||||
```python
|
||||
total_cost: float = product_price * products_bought * (interest_rate / 100)
|
||||
cost_per_user: float = total_cost / number_of_users
|
||||
new_user_balance = user_balance - math.ceil(cost_per_user)
|
||||
```
|
||||
TODO: ordering the purchases in favor of the user.
|
||||
|
||||
### Joint purchases where a user appears more than one time
|
||||
When performing joint purchases (multiple users
|
||||
|
||||
When a user appears more than once in a joint purchase (e.g. two people buying together, but one of them is buying twice as much as the other), the system will the amount of times a user appears in the purchase as a multiplier for the base price. You can think of it as if the user is having shares in the joint purchase.
|
||||
|
||||
```python
|
||||
base_cost_for_user: float = product_price * products_bought * user_shares / total_user_shares
|
||||
added_interest: float = base_cost_for_user * ((interest_rate - 100) / 100)
|
||||
new_user_balance: int = user_balance - math.ceil(base_cost_for_user + added_interest)
|
||||
```
|
||||
|
||||
### Joint purchases when one or more users are below the penalty threshold
|
||||
|
||||
The cost for each user will be calculated as usual, but for the users who are below the penalty threshold, the penalty will also be calculated and added to this user's cost. The penalty is calculated based on the share of the total purchase that this user is responsible for.
|
||||
|
||||
|
||||
```python
|
||||
base_cost_for_user: float = product_price * products_bought * user_shares / total_user_shares
|
||||
added_interest: float = base_cost_for_user * ((interest_rate - 100) / 100)
|
||||
penalty: float = base_cost_for_user * ((penalty_multiplier - 100) / 100)
|
||||
new_user_balance: int = user_balance - math.ceil(base_cost_for_user + added_interest + penalty)
|
||||
```
|
||||
TODO
|
||||
|
||||
### Joint purchases when one or more users will end up below the penalty threshold after the purchase
|
||||
|
||||
Just as the single-user case, if a user who is part of a joint purchase is above the penalty threshold before the purchase, but will end up below the threshold after the purchase, no penalty will be applied to that user for this purchase. The entire cost (including interest) will be split equally between all users.
|
||||
|
||||
```python
|
||||
base_cost_for_user: float = product_price * products_bought * user_shares / total_user_shares
|
||||
added_interest: float = base_cost_for_user * ((interest_rate - 100) / 100)
|
||||
new_user_balance: int = user_balance - math.ceil(base_cost_for_user + added_interest)
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> In the case where you (and others) are performing multiple transactions at once, the system should try its best to order the purchases in a way that minimizes the amount of penalties you need to pay.
|
||||
TODO
|
||||
|
||||
## Who owns a product
|
||||
|
||||
@@ -238,26 +197,12 @@ Upon throwing away products (not manual adjustment), the system will pull money
|
||||
|
||||
## Other actions
|
||||
|
||||
### Transfers
|
||||
Transfers
|
||||
|
||||
You can transfer money from one user to another. The amount transferred will be deducted from the sender's balance and added to the receiver's balance without any interest or penalty applied.
|
||||
Note about self-transfers
|
||||
|
||||
```python
|
||||
new_sender_balance: int = sender_balance - amount_transferred
|
||||
new_receiver_balance: int = receiver_balance + amount_transferred
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Transfers from one user to itself are not allowed.
|
||||
|
||||
### Balance adjustments
|
||||
|
||||
You can manually adjust a user's balance. This action will not have any multipliers of any kind applied, and will simply add or subtract the specified amount from the user's balance.
|
||||
|
||||
```python
|
||||
new_user_balance: int = user_balance + adjustment_amount
|
||||
```
|
||||
Balance adjustments
|
||||
|
||||
## Updating the economy specification
|
||||
|
||||
All transactions in the database are tagged with the economy specification version they were created under. If you are to update this document with changes to how the economy works, and change the software accordingly, you will want to keep the old logic around and bump the version number. This way, the old event log is still valid, and will be aggregated using the old logic, while new transactions will user the logic applicable to the version they were created under.
|
||||
Keep old logic, database rows tagged with spec version.
|
||||
|
||||
43
flake.nix
43
flake.nix
@@ -17,31 +17,21 @@
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in f system pkgs);
|
||||
in {
|
||||
apps = let
|
||||
mkApp = program: description: {
|
||||
type = "app";
|
||||
program = toString program;
|
||||
meta = {
|
||||
inherit description;
|
||||
};
|
||||
packages = forAllSystems (system: pkgs: {
|
||||
default = self.packages.${system}.dibbler;
|
||||
dibbler = pkgs.callPackage ./nix/dibbler.nix {
|
||||
python3Packages = pkgs.python312Packages;
|
||||
};
|
||||
mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
|
||||
in forAllSystems (system: pkgs: {
|
||||
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;
|
||||
};
|
||||
vm = mkVm "vm" "Start a NixOS VM with dibbler installed in kiosk-mode";
|
||||
vm-non-kiosk = mkVm "vm-non-kiosk" "Start a NixOS VM with dibbler installed in nonkiosk-mode";
|
||||
});
|
||||
|
||||
nixosModules.default = import ./nix/module.nix;
|
||||
|
||||
nixosConfigurations = {
|
||||
vm = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; };
|
||||
vm-non-kiosk = import ./nix/nixos-configurations/vm-non-kiosk.nix { inherit self nixpkgs; };
|
||||
};
|
||||
|
||||
overlays = {
|
||||
default = self.overlays.dibbler;
|
||||
dibbler = final: prev: {
|
||||
@@ -56,11 +46,20 @@
|
||||
};
|
||||
});
|
||||
|
||||
packages = forAllSystems (system: pkgs: {
|
||||
default = self.packages.${system}.dibbler;
|
||||
dibbler = pkgs.callPackage ./nix/package.nix {
|
||||
python3Packages = pkgs.python312Packages;
|
||||
# Note: using the module requires that you have applied the overlay first
|
||||
nixosModules.default = import ./nix/module.nix;
|
||||
|
||||
nixosConfigurations.skrot = nixpkgs.lib.nixosSystem (rec {
|
||||
system = "aarch64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ self.overlays.dibbler ];
|
||||
};
|
||||
modules = [
|
||||
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
|
||||
self.nixosModules.default
|
||||
./nix/skrott.nix
|
||||
];
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
32
nix/dibbler.nix
Normal file
32
nix/dibbler.nix
Normal file
@@ -0,0 +1,32 @@
|
||||
{ lib
|
||||
, python3Packages
|
||||
, fetchFromGitHub
|
||||
}:
|
||||
python3Packages.buildPythonApplication {
|
||||
pname = "dibbler";
|
||||
version = "unstable";
|
||||
src = lib.cleanSource ../.;
|
||||
|
||||
format = "pyproject";
|
||||
|
||||
# brother-ql is breaky breaky
|
||||
# https://github.com/NixOS/nixpkgs/issues/285234
|
||||
dontCheckRuntimeDeps = true;
|
||||
|
||||
pythonImportsCheck = [];
|
||||
|
||||
doCheck = true;
|
||||
nativeCheckInputs = with python3Packages; [
|
||||
pytest
|
||||
pytestCheckHook
|
||||
];
|
||||
|
||||
nativeBuildInputs = with python3Packages; [ setuptools ];
|
||||
propagatedBuildInputs = with python3Packages; [
|
||||
brother-ql
|
||||
matplotlib
|
||||
psycopg2-binary
|
||||
python-barcode
|
||||
sqlalchemy
|
||||
];
|
||||
}
|
||||
204
nix/module.nix
204
nix/module.nix
@@ -8,45 +8,6 @@ in {
|
||||
|
||||
package = lib.mkPackageOption pkgs "dibbler" { };
|
||||
|
||||
screenPackage = lib.mkPackageOption pkgs "screen" { };
|
||||
|
||||
createLocalDatabase = lib.mkEnableOption "" // {
|
||||
description = ''
|
||||
Whether to set up a local postgres database automatically.
|
||||
|
||||
::: {.note}
|
||||
You must set up postgres manually before enabling this option.
|
||||
:::
|
||||
'';
|
||||
};
|
||||
|
||||
kioskMode = lib.mkEnableOption "" // {
|
||||
description = ''
|
||||
Whether to let dibbler take over the entire machine.
|
||||
|
||||
This will restrict the machine to a single TTY and make the program unquittable.
|
||||
You can still get access to PTYs via SSH and similar, if enabled.
|
||||
'';
|
||||
};
|
||||
|
||||
limitScreenHeight = lib.mkOption {
|
||||
type = with lib.types; nullOr ints.unsigned;
|
||||
default = null;
|
||||
example = 42;
|
||||
description = ''
|
||||
If set, limits the height of the screen dibbler uses to the given number of lines.
|
||||
'';
|
||||
};
|
||||
|
||||
limitScreenWidth = lib.mkOption {
|
||||
type = with lib.types; nullOr ints.unsigned;
|
||||
default = null;
|
||||
example = 80;
|
||||
description = ''
|
||||
If set, limits the width of the screen dibbler uses to the given number of columns.
|
||||
'';
|
||||
};
|
||||
|
||||
settings = lib.mkOption {
|
||||
description = "Configuration for dibbler";
|
||||
default = { };
|
||||
@@ -56,128 +17,61 @@ in {
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||
{
|
||||
services.dibbler.settings = lib.pipe ../example-config.ini [
|
||||
builtins.readFile
|
||||
builtins.fromTOML
|
||||
(lib.mapAttrsRecursive (_: lib.mkDefault))
|
||||
];
|
||||
}
|
||||
{
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
config = let
|
||||
screen = "${pkgs.screen}/bin/screen";
|
||||
in lib.mkIf cfg.enable {
|
||||
services.dibbler.settings = lib.pipe ../example-config.ini [
|
||||
builtins.readFile
|
||||
builtins.fromTOML
|
||||
(lib.mapAttrsRecursive (_: lib.mkDefault))
|
||||
];
|
||||
|
||||
environment.etc."dibbler/dibbler.conf".source = format.generate "dibbler.conf" cfg.settings;
|
||||
boot = {
|
||||
consoleLogLevel = 0;
|
||||
enableContainers = false;
|
||||
loader.grub.enable = false;
|
||||
};
|
||||
|
||||
users = {
|
||||
users.dibbler = {
|
||||
group = "dibbler";
|
||||
isNormalUser = true;
|
||||
};
|
||||
groups.dibbler = { };
|
||||
};
|
||||
|
||||
services.dibbler.settings.database.url = lib.mkIf cfg.createLocalDatabase "postgresql://dibbler?host=/run/postgresql";
|
||||
|
||||
services.postgresql = lib.mkIf cfg.createLocalDatabase {
|
||||
ensureDatabases = [ "dibbler" ];
|
||||
ensureUsers = [{
|
||||
name = "dibbler";
|
||||
ensureDBOwnership = true;
|
||||
ensureClauses.login = true;
|
||||
}];
|
||||
};
|
||||
|
||||
systemd.services.dibbler-setup-database = lib.mkIf cfg.createLocalDatabase {
|
||||
description = "Dibbler database setup";
|
||||
wantedBy = [ "default.target" ];
|
||||
after = [ "postgresql.service" ];
|
||||
unitConfig = {
|
||||
ConditionPathExists = "!/var/lib/dibbler/.db-setup-done";
|
||||
};
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${lib.getExe cfg.package} --config /etc/dibbler/dibbler.conf create-db";
|
||||
ExecStartPost = "${lib.getExe' pkgs.coreutils "touch"} /var/lib/dibbler/.db-setup-done";
|
||||
StateDirectory = "dibbler";
|
||||
|
||||
User = "dibbler";
|
||||
Group = "dibbler";
|
||||
};
|
||||
};
|
||||
}
|
||||
(lib.mkIf cfg.kioskMode {
|
||||
boot.kernelParams = [
|
||||
"console=tty1"
|
||||
];
|
||||
|
||||
|
||||
users.users.dibbler = {
|
||||
users = {
|
||||
groups.dibbler = { };
|
||||
users.dibbler = {
|
||||
group = "dibbler";
|
||||
extraGroups = [ "lp" ];
|
||||
shell = (pkgs.writeShellScriptBin "login-shell" "${lib.getExe cfg.screenPackage} -x dibbler") // {
|
||||
shellPath = "/bin/login-shell";
|
||||
};
|
||||
isNormalUser = true;
|
||||
shell = (pkgs.writeShellScriptBin "login-shell" "${screen} -x dibbler") // {shellPath = "/bin/login-shell";};
|
||||
};
|
||||
};
|
||||
|
||||
services.dibbler.settings.general = {
|
||||
quit_allowed = false;
|
||||
stop_allowed = false;
|
||||
systemd.services.screen-daemon = {
|
||||
description = "Dibbler service screen";
|
||||
wantedBy = [ "default.target" ];
|
||||
serviceConfig = {
|
||||
ExecStartPre = "-${screen} -X -S dibbler kill";
|
||||
ExecStart = let
|
||||
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";
|
||||
User = "dibbler";
|
||||
Group = "dibbler";
|
||||
Type = "forking";
|
||||
RemainAfterExit = false;
|
||||
Restart = "always";
|
||||
RestartSec = "5s";
|
||||
SuccessExitStatus = 1;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.services.dibbler-screen-session = {
|
||||
description = "Dibbler Screen Session";
|
||||
wantedBy = [
|
||||
"default.target"
|
||||
];
|
||||
after = if cfg.createLocalDatabase then [
|
||||
"postgresql.service"
|
||||
"dibbler-setup-database.service"
|
||||
] else [
|
||||
"network.target"
|
||||
];
|
||||
serviceConfig = {
|
||||
Type = "forking";
|
||||
RemainAfterExit = false;
|
||||
Restart = "always";
|
||||
RestartSec = "5s";
|
||||
SuccessExitStatus = 1;
|
||||
# https://github.com/NixOS/nixpkgs/issues/84105
|
||||
boot.kernelParams = [
|
||||
"console=ttyUSB0,9600"
|
||||
"console=tty1"
|
||||
];
|
||||
systemd.services."serial-getty@ttyUSB0" = {
|
||||
enable = true;
|
||||
wantedBy = [ "getty.target" ]; # to start at boot
|
||||
serviceConfig.Restart = "always"; # restart when session is closed
|
||||
};
|
||||
|
||||
User = "dibbler";
|
||||
Group = "dibbler";
|
||||
|
||||
ExecStartPre = "-${lib.getExe cfg.screenPackage} -X -S dibbler kill";
|
||||
ExecStart = let
|
||||
screenArgs = lib.escapeShellArgs [
|
||||
# -dm creates the screen in detached mode without accessing it
|
||||
"-dm"
|
||||
|
||||
# Session name
|
||||
"-S"
|
||||
"dibbler"
|
||||
|
||||
# Set optimal output mode instead of VT100 emulation
|
||||
"-O"
|
||||
|
||||
# Enable login mode, updates utmp entries
|
||||
"-l"
|
||||
];
|
||||
|
||||
dibblerArgs = lib.cli.toCommandLineShellGNU { } {
|
||||
config = "/etc/dibbler/dibbler.conf";
|
||||
};
|
||||
|
||||
in "${lib.getExe cfg.screenPackage} ${screenArgs} ${lib.getExe cfg.package} ${dibblerArgs} loop";
|
||||
ExecStartPost =
|
||||
lib.optionals (cfg.limitScreenWidth != null) [
|
||||
"${lib.getExe cfg.screenPackage} -X -S dibbler width ${toString cfg.limitScreenWidth}"
|
||||
]
|
||||
++ lib.optionals (cfg.limitScreenHeight != null) [
|
||||
"${lib.getExe cfg.screenPackage} -X -S dibbler height ${toString cfg.limitScreenHeight}"
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
services.getty.autologinUser = "dibbler";
|
||||
})
|
||||
]);
|
||||
services.getty.autologinUser = lib.mkForce "dibbler";
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
{ self, nixpkgs, ... }:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [
|
||||
self.overlays.dibbler
|
||||
];
|
||||
};
|
||||
modules = [
|
||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||
"${nixpkgs}/nixos/tests/common/user-account.nix"
|
||||
|
||||
self.nixosModules.default
|
||||
|
||||
({ config, ... }: {
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
virtualisation.graphics = false;
|
||||
|
||||
users.motd = ''
|
||||
=================================
|
||||
Welcome to the dibbler non-kiosk vm!
|
||||
|
||||
Try running:
|
||||
${config.services.dibbler.package.meta.mainProgram} loop
|
||||
|
||||
Password for dibbler is 'dibbler'
|
||||
|
||||
To exit, press Ctrl+A, then X
|
||||
=================================
|
||||
'';
|
||||
|
||||
users.users.dibbler = {
|
||||
isNormalUser = true;
|
||||
password = "dibbler";
|
||||
extraGroups = [ "wheel" ];
|
||||
};
|
||||
|
||||
services.getty.autologinUser = "dibbler";
|
||||
|
||||
programs.vim = {
|
||||
enable = true;
|
||||
defaultEditor = true;
|
||||
};
|
||||
|
||||
services.postgresql.enable = true;
|
||||
|
||||
services.dibbler = {
|
||||
enable = true;
|
||||
createLocalDatabase = true;
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{ self, nixpkgs, ... }:
|
||||
nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
pkgs = import nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
overlays = [
|
||||
self.overlays.default
|
||||
];
|
||||
};
|
||||
modules = [
|
||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||
"${nixpkgs}/nixos/tests/common/user-account.nix"
|
||||
|
||||
self.nixosModules.default
|
||||
|
||||
({ config, ... }: {
|
||||
system.stateVersion = config.system.nixos.release;
|
||||
virtualisation.graphics = false;
|
||||
|
||||
services.postgresql.enable = true;
|
||||
|
||||
services.dibbler = {
|
||||
enable = true;
|
||||
createLocalDatabase = true;
|
||||
kioskMode = true;
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
{ lib
|
||||
, python3Packages
|
||||
, makeWrapper
|
||||
, less
|
||||
}:
|
||||
let
|
||||
pyproject = builtins.fromTOML (builtins.readFile ../pyproject.toml);
|
||||
in
|
||||
python3Packages.buildPythonApplication {
|
||||
pname = pyproject.project.name;
|
||||
version = pyproject.project.version;
|
||||
src = lib.cleanSource ../.;
|
||||
|
||||
format = "pyproject";
|
||||
|
||||
# brother-ql is breaky breaky
|
||||
# https://github.com/NixOS/nixpkgs/issues/285234
|
||||
dontCheckRuntimeDeps = true;
|
||||
|
||||
nativeBuildInputs = with python3Packages; [
|
||||
setuptools
|
||||
makeWrapper
|
||||
];
|
||||
propagatedBuildInputs = with python3Packages; [
|
||||
brother-ql
|
||||
matplotlib
|
||||
psycopg2-binary
|
||||
python-barcode
|
||||
sqlalchemy
|
||||
];
|
||||
|
||||
pythonImportsCheck = [];
|
||||
|
||||
doCheck = true;
|
||||
nativeCheckInputs = with python3Packages; [
|
||||
pytest
|
||||
pytestCheckHook
|
||||
sqlparse
|
||||
pytest-html
|
||||
pytest-cov
|
||||
pytest-benchmark
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
wrapProgram $out/bin/dibbler \
|
||||
--prefix PATH : "${lib.makeBinPath [ less ]}"
|
||||
'';
|
||||
|
||||
meta = {
|
||||
description = "The little kiosk that could";
|
||||
mainProgram = "dibbler";
|
||||
};
|
||||
}
|
||||
@@ -20,8 +20,6 @@ mkShell {
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-html
|
||||
pytest-benchmark
|
||||
pygal
|
||||
]))
|
||||
];
|
||||
}
|
||||
|
||||
27
nix/skrott.nix
Normal file
27
nix/skrott.nix
Normal file
@@ -0,0 +1,27 @@
|
||||
{...}: {
|
||||
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" ];
|
||||
# };
|
||||
}
|
||||
@@ -4,10 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "dibbler"
|
||||
version = "1.0.0"
|
||||
authors = [
|
||||
{ name = "Programvareverkstedet", email = "projects@pvv.ntnu.no" }
|
||||
]
|
||||
authors = []
|
||||
description = "EDB-system for PVV"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -21,6 +18,7 @@ dependencies = [
|
||||
"psycopg2-binary >= 2.8, <2.10",
|
||||
"python-barcode",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
@@ -29,7 +27,6 @@ test = [
|
||||
"coverage-badge>=1.1.2",
|
||||
"pytest-html>=4.1.1",
|
||||
"sqlparse>=0.5.4",
|
||||
"pytest-benchmark[histogram]>=5.2.3",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -43,22 +40,3 @@ line-length = 100
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = [
|
||||
"--cov=dibbler.lib",
|
||||
"--cov=dibbler.models",
|
||||
"--cov=dibbler.queries",
|
||||
"--cov-report=html",
|
||||
"--cov-branch",
|
||||
|
||||
"--self-contained-html",
|
||||
"--html=./test-report/index.html",
|
||||
|
||||
"--benchmark-skip",
|
||||
"--benchmark-autosave",
|
||||
"--benchmark-save=default",
|
||||
"--benchmark-verbose",
|
||||
"--benchmark-storage=benchmark",
|
||||
"--benchmark-histogram=benchmark/histogram",
|
||||
]
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
TRANSACTION_GENERATOR_EXCEPTION_LIMIT = 15
|
||||
"""
|
||||
The random transaction generator uses a set seed to generate transactions.
|
||||
However, not all transactions are valid in all contexts. We catch illegal
|
||||
generated transactions with a try/except, and retry until we generate a valid
|
||||
one. However, if we exceed this limit, something is likely wrong with the generator
|
||||
instead, due to the unlikely high number of exceptions.
|
||||
"""
|
||||
|
||||
BENCHMARK_ITERATIONS = 5
|
||||
BENCHMARK_ROUNDS = 3
|
||||
@@ -1,304 +0,0 @@
|
||||
import random
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, TransactionType, User
|
||||
from dibbler.queries import joint_buy_product
|
||||
from tests.benchmark.benchmark_settings import TRANSACTION_GENERATOR_EXCEPTION_LIMIT
|
||||
|
||||
|
||||
def insert_users_and_products(
|
||||
sql_session: Session, user_count: int = 10, product_count: int = 10
|
||||
) -> tuple[list[User], list[Product]]:
|
||||
users = []
|
||||
for i in range(user_count):
|
||||
user = User(f"User{i + 1}")
|
||||
sql_session.add(user)
|
||||
users.append(user)
|
||||
sql_session.commit()
|
||||
|
||||
products = []
|
||||
for i in range(product_count):
|
||||
barcode = str(1000000000000 + i)
|
||||
product = Product(barcode, f"Product{i + 1}")
|
||||
sql_session.add(product)
|
||||
products.append(product)
|
||||
sql_session.commit()
|
||||
|
||||
return users, products
|
||||
|
||||
|
||||
def generate_random_transactions(
|
||||
sql_session: Session,
|
||||
n: int,
|
||||
seed: int = 42,
|
||||
transaction_type_filter: list[TransactionType] | None = None,
|
||||
distribution: dict[TransactionType, float] | None = None,
|
||||
cache_every_n: int | None = None,
|
||||
) -> list[Transaction]:
|
||||
random.seed(seed)
|
||||
|
||||
if transaction_type_filter is None:
|
||||
transaction_type_filter = list(TransactionType)
|
||||
|
||||
if TransactionType.JOINT_BUY_PRODUCT in transaction_type_filter:
|
||||
transaction_type_filter.remove(TransactionType.JOINT_BUY_PRODUCT)
|
||||
|
||||
# TODO: implement me
|
||||
if TransactionType.THROW_PRODUCT in transaction_type_filter:
|
||||
transaction_type_filter.remove(TransactionType.THROW_PRODUCT)
|
||||
|
||||
if distribution is None:
|
||||
distribution = {t: 1 / len(transaction_type_filter) for t in transaction_type_filter}
|
||||
transaction_types = list(distribution.keys())
|
||||
weights = list(distribution.values())
|
||||
transactions: list[Transaction] = []
|
||||
last_time = datetime(2023, 1, 1, 0, 0, 0)
|
||||
for _ in range(n):
|
||||
transaction_type = random.choices(transaction_types, weights=weights, k=1)[0]
|
||||
generator = RANDOM_GENERATORS[transaction_type]
|
||||
transaction_or_transactions = generator(sql_session, last_time)
|
||||
if isinstance(transaction_or_transactions, list):
|
||||
transactions.extend(transaction_or_transactions)
|
||||
last_time = max(t.time for t in transaction_or_transactions)
|
||||
else:
|
||||
transactions.append(transaction_or_transactions)
|
||||
last_time = transaction_or_transactions.time
|
||||
return transactions
|
||||
|
||||
|
||||
def random_add_product_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
product = random.choice(sql_session.query(Product).all())
|
||||
product_count = random.randint(1, 10)
|
||||
product_price = random.randint(15, 45)
|
||||
amount = product_count * product_price + random.randint(-7, 0)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.add_product(
|
||||
amount,
|
||||
user.id,
|
||||
product.id,
|
||||
product_price,
|
||||
product_count,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_adjust_balance_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
amount = random.randint(-50, 100)
|
||||
if amount == 0:
|
||||
amount = 1
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.adjust_balance(
|
||||
amount,
|
||||
user.id,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_adjust_interest_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
amount = random.randint(100, 105)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.adjust_interest(
|
||||
amount,
|
||||
user.id,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_adjust_penalty_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
penalty_multiplier_percent = random.randint(100, 200)
|
||||
penalty_threshold = random.randint( -150, -50)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.adjust_penalty(
|
||||
penalty_multiplier_percent,
|
||||
penalty_threshold,
|
||||
user.id,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_adjust_stock_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
product = random.choice(sql_session.query(Product).all())
|
||||
stock_change = random.randint(-5, 6)
|
||||
if stock_change == 0:
|
||||
stock_change = 1
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.adjust_stock(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=stock_change,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_buy_product_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
product = random.choice(sql_session.query(Product).all())
|
||||
product_count = random.randint(1, 5)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.buy_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=product_count,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_joint_transaction(sql_session: Session, last_time: datetime) -> list[Transaction]:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user_count = random.randint(2, 4)
|
||||
users = random.sample(sql_session.query(User).all(), k=user_count)
|
||||
product = random.choice(sql_session.query(Product).all())
|
||||
product_count = random.randint(1, 5)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
|
||||
try:
|
||||
transactions = joint_buy_product(
|
||||
sql_session,
|
||||
product=product,
|
||||
product_count=product_count,
|
||||
instigator=users[0],
|
||||
users=users,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transactions
|
||||
|
||||
|
||||
def random_transfer_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
sender, receiver = random.sample(sql_session.query(User).all(), k=2)
|
||||
amount = random.randint(1, 50)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.transfer(
|
||||
amount,
|
||||
sender.id,
|
||||
receiver.id,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
def random_throw_product_transaction(sql_session: Session, last_time: datetime) -> Transaction:
|
||||
i = 0
|
||||
while True:
|
||||
i += 1
|
||||
user = random.choice(sql_session.query(User).all())
|
||||
product = random.choice(sql_session.query(Product).all())
|
||||
product_count = random.randint(1, 5)
|
||||
new_datetime = last_time + timedelta(minutes=random.randint(1, 60))
|
||||
try:
|
||||
transaction = Transaction.throw_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=product_count,
|
||||
time=new_datetime,
|
||||
)
|
||||
except Exception:
|
||||
if i > TRANSACTION_GENERATOR_EXCEPTION_LIMIT:
|
||||
raise RuntimeError(
|
||||
"Too many failed attempts to create a valid transaction, consider changing the seed"
|
||||
)
|
||||
continue
|
||||
return transaction
|
||||
|
||||
|
||||
RANDOM_GENERATORS = {
|
||||
TransactionType.ADD_PRODUCT: random_add_product_transaction,
|
||||
TransactionType.ADJUST_BALANCE: random_adjust_balance_transaction,
|
||||
TransactionType.ADJUST_INTEREST: random_adjust_interest_transaction,
|
||||
TransactionType.ADJUST_PENALTY: random_adjust_penalty_transaction,
|
||||
TransactionType.ADJUST_STOCK: random_adjust_stock_transaction,
|
||||
TransactionType.BUY_PRODUCT: random_buy_product_transaction,
|
||||
TransactionType.JOINT: random_joint_transaction,
|
||||
TransactionType.TRANSFER: random_transfer_transaction,
|
||||
TransactionType.THROW_PRODUCT: random_throw_product_transaction,
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, TransactionType
|
||||
from dibbler.queries import product_owners
|
||||
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
|
||||
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
|
||||
|
||||
|
||||
@pytest.mark.benchmark(group="product_owners")
|
||||
@pytest.mark.parametrize(
|
||||
"transaction_count",
|
||||
[
|
||||
100,
|
||||
500,
|
||||
1000,
|
||||
2000,
|
||||
5000,
|
||||
10000,
|
||||
],
|
||||
)
|
||||
def test_benchmark_product_owners(
|
||||
benchmark,
|
||||
sql_session: Session,
|
||||
transaction_count: int,
|
||||
):
|
||||
_users, products = insert_users_and_products(sql_session)
|
||||
|
||||
transactions = generate_random_transactions(
|
||||
sql_session,
|
||||
transaction_count,
|
||||
transaction_type_filter=[
|
||||
TransactionType.ADD_PRODUCT,
|
||||
TransactionType.ADJUST_STOCK,
|
||||
TransactionType.BUY_PRODUCT,
|
||||
TransactionType.JOINT,
|
||||
TransactionType.THROW_PRODUCT,
|
||||
],
|
||||
)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
benchmark.pedantic(
|
||||
query_all_product_owners,
|
||||
args=(sql_session, products),
|
||||
iterations=BENCHMARK_ITERATIONS,
|
||||
rounds=BENCHMARK_ROUNDS,
|
||||
)
|
||||
|
||||
|
||||
def query_all_product_owners(sql_session: Session, products: list[Product]) -> None:
|
||||
for product in products:
|
||||
product_owners(sql_session, product, use_cache=False)
|
||||
@@ -1,92 +0,0 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, TransactionType
|
||||
from dibbler.queries import product_price, update_cache
|
||||
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
|
||||
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
|
||||
|
||||
# @pytest.mark.benchmark(group="product_price")
|
||||
# @pytest.mark.parametrize(
|
||||
# "transaction_count",
|
||||
# [
|
||||
# 100,
|
||||
# 500,
|
||||
# 1000,
|
||||
# 2000,
|
||||
# 5000,
|
||||
# 10000,
|
||||
# ],
|
||||
# )
|
||||
# def test_benchmark_product_price(benchmark, sql_session: Session, transaction_count):
|
||||
# _users, products = insert_users_and_products(sql_session)
|
||||
|
||||
# transactions = generate_random_transactions(
|
||||
# sql_session,
|
||||
# transaction_count,
|
||||
# transaction_type_filter=[
|
||||
# TransactionType.ADD_PRODUCT,
|
||||
# TransactionType.ADJUST_STOCK,
|
||||
# TransactionType.BUY_PRODUCT,
|
||||
# TransactionType.JOINT,
|
||||
# TransactionType.THROW_PRODUCT,
|
||||
# ],
|
||||
# )
|
||||
|
||||
# sql_session.add_all(transactions)
|
||||
# sql_session.commit()
|
||||
|
||||
# benchmark.pedantic(
|
||||
# query_all_products_price,
|
||||
# args=(sql_session, products),
|
||||
# iterations=BENCHMARK_ITERATIONS,
|
||||
# rounds=BENCHMARK_ROUNDS,
|
||||
# )
|
||||
|
||||
|
||||
@pytest.mark.benchmark(group="product_price")
|
||||
@pytest.mark.parametrize(
|
||||
"transaction_count",
|
||||
[
|
||||
1000,
|
||||
2000,
|
||||
5000,
|
||||
10000,
|
||||
],
|
||||
)
|
||||
def test_benchmark_product_price_cache_every_500(
|
||||
benchmark,
|
||||
sql_session: Session,
|
||||
transaction_count: int,
|
||||
):
|
||||
users, _products = insert_users_and_products(sql_session)
|
||||
|
||||
transactions = generate_random_transactions(
|
||||
sql_session,
|
||||
transaction_count,
|
||||
transaction_type_filter=[
|
||||
TransactionType.ADD_PRODUCT,
|
||||
TransactionType.ADJUST_STOCK,
|
||||
TransactionType.BUY_PRODUCT,
|
||||
TransactionType.JOINT,
|
||||
TransactionType.THROW_PRODUCT,
|
||||
],
|
||||
)
|
||||
|
||||
for i in range(0, len(transactions), 500):
|
||||
update_cache(sql_session)
|
||||
|
||||
sql_session.add_all(transactions[i : i + 500])
|
||||
sql_session.commit()
|
||||
|
||||
benchmark.pedantic(
|
||||
query_all_products_price,
|
||||
args=(sql_session, users),
|
||||
iterations=BENCHMARK_ITERATIONS,
|
||||
rounds=BENCHMARK_ROUNDS,
|
||||
)
|
||||
|
||||
|
||||
def query_all_products_price(sql_session: Session, products: list[Product]) -> None:
|
||||
for product in products:
|
||||
product_price(sql_session, product, use_cache=False)
|
||||
@@ -1,54 +0,0 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, TransactionType
|
||||
from dibbler.queries import product_stock
|
||||
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
|
||||
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
|
||||
|
||||
|
||||
@pytest.mark.benchmark(group="product_stock")
|
||||
@pytest.mark.parametrize(
|
||||
"transaction_count",
|
||||
[
|
||||
100,
|
||||
500,
|
||||
1000,
|
||||
2000,
|
||||
5000,
|
||||
10000,
|
||||
],
|
||||
)
|
||||
def test_benchmark_product_stock(
|
||||
benchmark,
|
||||
sql_session: Session,
|
||||
transaction_count: int,
|
||||
):
|
||||
_users, products = insert_users_and_products(sql_session)
|
||||
|
||||
transactions = generate_random_transactions(
|
||||
sql_session,
|
||||
transaction_count,
|
||||
transaction_type_filter=[
|
||||
TransactionType.ADD_PRODUCT,
|
||||
TransactionType.ADJUST_STOCK,
|
||||
TransactionType.BUY_PRODUCT,
|
||||
TransactionType.JOINT,
|
||||
TransactionType.THROW_PRODUCT,
|
||||
],
|
||||
)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
benchmark.pedantic(
|
||||
query_all_products_stock,
|
||||
args=(sql_session, products),
|
||||
iterations=BENCHMARK_ITERATIONS,
|
||||
rounds=BENCHMARK_ROUNDS,
|
||||
)
|
||||
|
||||
|
||||
def query_all_products_stock(sql_session: Session, products: list[Product]) -> None:
|
||||
for product in products:
|
||||
product_stock(sql_session, product, use_cache=False)
|
||||
@@ -1,54 +0,0 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, User
|
||||
from dibbler.queries import transaction_log
|
||||
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
|
||||
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
|
||||
|
||||
|
||||
@pytest.mark.benchmark(group="transaction_log")
|
||||
@pytest.mark.parametrize(
|
||||
"transaction_count",
|
||||
[
|
||||
100,
|
||||
500,
|
||||
1000,
|
||||
2000,
|
||||
5000,
|
||||
10000,
|
||||
],
|
||||
)
|
||||
def test_benchmark_transaction_log(
|
||||
benchmark,
|
||||
sql_session: Session,
|
||||
transaction_count: int,
|
||||
):
|
||||
users, products = insert_users_and_products(sql_session)
|
||||
|
||||
transactions = generate_random_transactions(
|
||||
sql_session,
|
||||
transaction_count,
|
||||
)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
benchmark.pedantic(
|
||||
query_transaction_log,
|
||||
args=(
|
||||
sql_session,
|
||||
products,
|
||||
users,
|
||||
),
|
||||
iterations=BENCHMARK_ITERATIONS,
|
||||
rounds=BENCHMARK_ROUNDS,
|
||||
)
|
||||
|
||||
|
||||
def query_transaction_log(sql_session: Session, products: list[Product], users: list[User]) -> None:
|
||||
for user in users:
|
||||
transaction_log(sql_session, user=user)
|
||||
|
||||
for product in products:
|
||||
transaction_log(sql_session, product=product)
|
||||
@@ -1,81 +0,0 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import User
|
||||
from dibbler.queries import update_cache, user_balance
|
||||
from tests.benchmark.benchmark_settings import BENCHMARK_ITERATIONS, BENCHMARK_ROUNDS
|
||||
from tests.benchmark.helpers import generate_random_transactions, insert_users_and_products
|
||||
|
||||
|
||||
@pytest.mark.benchmark(group="user_balance")
|
||||
@pytest.mark.parametrize(
|
||||
"transaction_count",
|
||||
[
|
||||
100,
|
||||
500,
|
||||
1000,
|
||||
1500,
|
||||
2000,
|
||||
],
|
||||
)
|
||||
def test_benchmark_user_balance(
|
||||
benchmark,
|
||||
sql_session: Session,
|
||||
transaction_count: int,
|
||||
):
|
||||
users, _products = insert_users_and_products(sql_session)
|
||||
|
||||
transactions = generate_random_transactions(
|
||||
sql_session,
|
||||
transaction_count,
|
||||
)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
benchmark.pedantic(
|
||||
query_all_users_balance,
|
||||
args=(sql_session, users),
|
||||
iterations=BENCHMARK_ITERATIONS,
|
||||
rounds=BENCHMARK_ROUNDS,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.benchmark(group="user_balance")
|
||||
@pytest.mark.parametrize(
|
||||
"transaction_count",
|
||||
[
|
||||
1000,
|
||||
1500,
|
||||
2000,
|
||||
],
|
||||
)
|
||||
def test_benchmark_user_balance_cache_every_500(
|
||||
benchmark,
|
||||
sql_session: Session,
|
||||
transaction_count: int,
|
||||
):
|
||||
users, _products = insert_users_and_products(sql_session)
|
||||
|
||||
transactions = generate_random_transactions(
|
||||
sql_session,
|
||||
transaction_count,
|
||||
)
|
||||
|
||||
for i in range(0, len(transactions), 500):
|
||||
update_cache(sql_session)
|
||||
|
||||
sql_session.add_all(transactions[i : i + 500])
|
||||
sql_session.commit()
|
||||
|
||||
benchmark.pedantic(
|
||||
query_all_users_balance,
|
||||
args=(sql_session, users),
|
||||
iterations=BENCHMARK_ITERATIONS,
|
||||
rounds=BENCHMARK_ROUNDS,
|
||||
)
|
||||
|
||||
|
||||
def query_all_users_balance(sql_session: Session, users: list[User]) -> None:
|
||||
for user in users:
|
||||
user_balance(sql_session, user, use_cache=False)
|
||||
@@ -3,7 +3,7 @@ import logging
|
||||
import pytest
|
||||
import sqlparse
|
||||
from sqlalchemy import create_engine, event
|
||||
# from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.exc import OperationalError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Base
|
||||
@@ -66,7 +66,6 @@ def sql_session(request):
|
||||
Base.metadata.create_all(engine)
|
||||
with Session(engine) as sql_session:
|
||||
yield sql_session
|
||||
sql_session.close()
|
||||
|
||||
|
||||
# FIXME: Declaring this hook seems to have a side effect where the database does not
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from dibbler.models import Transaction
|
||||
|
||||
|
||||
def assign_times(
|
||||
transactions: list[Transaction],
|
||||
start_time: datetime = datetime(2024, 1, 1, 0, 0, 0),
|
||||
delta: timedelta = timedelta(minutes=1),
|
||||
) -> None:
|
||||
"""Assigns datetimes to a list of transactions starting from start_time and incrementing by delta."""
|
||||
current_time = start_time
|
||||
for transaction in transactions:
|
||||
transaction.time = current_time
|
||||
current_time += delta
|
||||
|
||||
|
||||
def assert_id_order_similar_to_time_order(transactions: list[Transaction]) -> None:
|
||||
"""Asserts that the order of transaction IDs is similar to the order of their timestamps."""
|
||||
sorted_by_time = sorted(transactions, key=lambda t: t.time)
|
||||
sorted_by_id = sorted(transactions, key=lambda t: t.id)
|
||||
|
||||
for t1, t2 in zip(sorted_by_time, sorted_by_id):
|
||||
assert t1.id == t2.id or t1.time == t2.time, (
|
||||
f"Transaction ID order does not match time order:\n"
|
||||
f"ID {t1.id} at time {t1.time}\n"
|
||||
f"ID {t2.id} at time {t2.time}"
|
||||
)
|
||||
@@ -123,6 +123,24 @@ def test_transaction_buy_product_more_than_stock(sql_session: Session) -> None:
|
||||
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:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import User
|
||||
from dibbler.models import Product, Transaction, User
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> User:
|
||||
|
||||
0
tests/queries/test_add_user.py
Normal file
0
tests/queries/test_add_user.py
Normal file
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -15,18 +15,6 @@ def insert_test_data(sql_session: Session) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def test_adjust_interest_uninitialized_user(sql_session: Session) -> None:
|
||||
user = User("Uninitialized User")
|
||||
|
||||
with pytest.raises(ValueError, match="User must be persisted in the database."):
|
||||
adjust_interest(
|
||||
sql_session,
|
||||
user=user,
|
||||
new_interest=4,
|
||||
message="Attempting to adjust interest for uninitialized user",
|
||||
)
|
||||
|
||||
|
||||
def test_adjust_interest_no_history(sql_session: Session) -> None:
|
||||
user = insert_test_data(sql_session)
|
||||
|
||||
@@ -65,7 +53,6 @@ def test_adjust_interest_existing_history(sql_session: Session) -> None:
|
||||
user=user,
|
||||
new_interest=2,
|
||||
message="Adjusting interest rate",
|
||||
time=transactions[-1].time + timedelta(days=1),
|
||||
)
|
||||
sql_session.commit()
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
)
|
||||
from dibbler.queries import adjust_penalty, current_penalty
|
||||
@@ -19,30 +19,6 @@ def insert_test_data(sql_session: Session) -> User:
|
||||
return user
|
||||
|
||||
|
||||
def test_adjust_penalty_empty_not_allowed(sql_session: Session) -> None:
|
||||
user = insert_test_data(sql_session)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
adjust_penalty(
|
||||
sql_session,
|
||||
user=user,
|
||||
message="No penalty or multiplier provided",
|
||||
)
|
||||
|
||||
|
||||
def test_adjust_penalty_uninitialized_user(sql_session: Session) -> None:
|
||||
user = User("Uninitialized User")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
adjust_penalty(
|
||||
sql_session,
|
||||
user=user,
|
||||
new_penalty=-100,
|
||||
new_penalty_multiplier=110,
|
||||
message="Attempting to adjust penalty for uninitialized user",
|
||||
)
|
||||
|
||||
|
||||
def test_adjust_penalty_no_history(sql_session: Session) -> None:
|
||||
user = insert_test_data(sql_session)
|
||||
|
||||
@@ -57,7 +33,7 @@ def test_adjust_penalty_no_history(sql_session: Session) -> None:
|
||||
(penalty, multiplier) = current_penalty(sql_session)
|
||||
|
||||
assert penalty == -200
|
||||
assert multiplier == DEFAULT_PENALTY_MULTIPLIER_PERCENT
|
||||
assert multiplier == DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE
|
||||
|
||||
|
||||
def test_adjust_penalty_multiplier_no_history(sql_session: Session) -> None:
|
||||
@@ -124,7 +100,6 @@ def test_adjust_penalty_existing_history(sql_session: Session) -> None:
|
||||
user=user,
|
||||
new_penalty=-250,
|
||||
message="Adjusting penalty threshold",
|
||||
time=transactions[-1].time + timedelta(days=1),
|
||||
)
|
||||
sql_session.commit()
|
||||
|
||||
@@ -155,7 +130,6 @@ def test_adjust_penalty_multiplier_existing_history(sql_session: Session) -> Non
|
||||
user=user,
|
||||
new_penalty_multiplier=130,
|
||||
message="Adjusting penalty multiplier",
|
||||
time=transactions[-1].time + timedelta(days=1),
|
||||
)
|
||||
sql_session.commit()
|
||||
(_, multiplier) = current_penalty(sql_session)
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
from dibbler.queries import affected_products
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> tuple[User, list[Product]]:
|
||||
user = User("Test User")
|
||||
|
||||
products = []
|
||||
for i in range(10):
|
||||
product = Product(f"12345678901{i:02d}", f"Test Product {i}")
|
||||
products.append(product)
|
||||
|
||||
sql_session.add(user)
|
||||
sql_session.add_all(products)
|
||||
sql_session.commit()
|
||||
|
||||
return user, products
|
||||
|
||||
|
||||
def test_affected_products_no_history(sql_session: Session) -> None:
|
||||
insert_test_data(sql_session)
|
||||
|
||||
result = affected_products(sql_session)
|
||||
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_affected_products_basic_history(sql_session: Session) -> None:
|
||||
user, products = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user.id,
|
||||
product_id=products[i].id,
|
||||
product_count=1,
|
||||
)
|
||||
for i in range(5)
|
||||
] + [
|
||||
Transaction.buy_product(
|
||||
user_id=user.id,
|
||||
product_id=products[i].id,
|
||||
product_count=1,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
result = affected_products(sql_session)
|
||||
|
||||
expected_products = {products[i] for i in range(5)}
|
||||
|
||||
assert result == expected_products
|
||||
|
||||
|
||||
# def test_affected_products_after(sql_session: Session) -> None:
|
||||
# def test_affected_products_until(sql_session: Session) -> None:
|
||||
# def test_affected_products_after_until(sql_session: Session) -> None:
|
||||
# def test_affected_products_after_inclusive(sql_session: Session) -> None:
|
||||
# def test_affected_products_until_inclusive(sql_session: Session) -> None:
|
||||
# def test_affected_products_after_until_inclusive(sql_session: Session) -> None:
|
||||
@@ -1,74 +0,0 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
from dibbler.queries import affected_users
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> tuple[list[User], Product]:
|
||||
users = []
|
||||
for i in range(10):
|
||||
user = User(f"Test User {i + 1}")
|
||||
users.append(user)
|
||||
|
||||
product = Product("1234567890123", "Test Product")
|
||||
|
||||
sql_session.add_all(users)
|
||||
sql_session.add(product)
|
||||
sql_session.commit()
|
||||
|
||||
return users, product
|
||||
|
||||
|
||||
def test_affected_users_no_history(sql_session: Session) -> None:
|
||||
insert_test_data(sql_session)
|
||||
|
||||
result = affected_users(sql_session)
|
||||
|
||||
assert result == set()
|
||||
|
||||
|
||||
def test_affected_users_basic_history(sql_session: Session) -> None:
|
||||
users, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=users[i].id,
|
||||
product_id=product.id,
|
||||
product_count=1,
|
||||
)
|
||||
for i in range(5)
|
||||
] + [
|
||||
Transaction.buy_product(
|
||||
user_id=users[i].id,
|
||||
product_id=product.id,
|
||||
product_count=1,
|
||||
)
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
result = affected_users(sql_session)
|
||||
|
||||
expected_users = {users[i] for i in range(5)}
|
||||
|
||||
assert result == expected_users
|
||||
|
||||
|
||||
# def test_affected_users_after(sql_session: Session) -> None:
|
||||
# def test_affected_users_until(sql_session: Session) -> None:
|
||||
# def test_affected_users_after_until(sql_session: Session) -> None:
|
||||
# def test_affected_users_after_inclusive(sql_session: Session) -> None:
|
||||
# def test_affected_users_until_inclusive(sql_session: Session) -> None:
|
||||
# def test_affected_users_after_until_inclusive(sql_session: Session) -> None:
|
||||
@@ -1,13 +1,14 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENT
|
||||
from dibbler.models.Transaction import DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
from dibbler.models import Transaction, User
|
||||
from dibbler.queries import current_interest
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
|
||||
def test_current_interest_no_history(sql_session: Session) -> None:
|
||||
assert current_interest(sql_session) == DEFAULT_INTEREST_RATE_PERCENT
|
||||
assert current_interest(sql_session) == DEFAULT_INTEREST_RATE_PERCENTAGE
|
||||
|
||||
|
||||
def test_current_interest_with_history(sql_session: Session) -> None:
|
||||
@@ -17,20 +18,18 @@ def test_current_interest_with_history(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.adjust_interest(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
interest_rate_percent=5,
|
||||
user_id=user.id,
|
||||
),
|
||||
Transaction.adjust_interest(
|
||||
time=datetime(2023, 11, 1, 10, 0, 0),
|
||||
interest_rate_percent=7,
|
||||
user_id=user.id,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
assert current_interest(sql_session) == 7
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Transaction, User
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
)
|
||||
from dibbler.queries import current_penalty
|
||||
from tests.helpers import assign_times, assert_id_order_similar_to_time_order
|
||||
|
||||
|
||||
def test_current_penalty_no_history(sql_session: Session) -> None:
|
||||
assert current_penalty(sql_session) == (
|
||||
DEFAULT_PENALTY_THRESHOLD,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE,
|
||||
)
|
||||
|
||||
|
||||
@@ -23,22 +24,19 @@ def test_current_penalty_with_history(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.adjust_penalty(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
penalty_threshold=-200,
|
||||
penalty_multiplier_percent=150,
|
||||
user_id=user.id,
|
||||
),
|
||||
Transaction.adjust_penalty(
|
||||
time=datetime(2023, 10, 2, 10, 0, 0),
|
||||
penalty_threshold=-300,
|
||||
penalty_multiplier_percent=200,
|
||||
user_id=user.id,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
assert current_penalty(sql_session) == (-300, 200)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -33,12 +33,12 @@ def insert_test_data(sql_session: Session) -> tuple[User, User, User, Product]:
|
||||
return user1, user2, user3, product
|
||||
|
||||
|
||||
def test_joint_buy_product_uninitialized_product(sql_session: Session) -> None:
|
||||
def test_joint_buy_product_missing_product(sql_session: Session) -> None:
|
||||
user = User("Test User 1")
|
||||
sql_session.add(user)
|
||||
sql_session.commit()
|
||||
|
||||
product = Product("1234567890123", "Uninitialized Product")
|
||||
product = Product("1234567890123", "Test Product")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
joint_buy_product(
|
||||
@@ -50,42 +50,18 @@ def test_joint_buy_product_uninitialized_product(sql_session: Session) -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_joint_buy_product_no_users(sql_session: Session) -> None:
|
||||
user, _, _, product = insert_test_data(sql_session)
|
||||
def test_joint_buy_product_missing_user(sql_session: Session) -> None:
|
||||
user = User("Test User 1")
|
||||
|
||||
product = Product("1234567890123", "Test Product")
|
||||
sql_session.add(product)
|
||||
sql_session.commit()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[],
|
||||
product=product,
|
||||
product_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_joint_buy_product_uninitialized_instigator(sql_session: Session) -> None:
|
||||
user, user2, _, product = insert_test_data(sql_session)
|
||||
|
||||
uninitialized_user = User("Uninitialized User")
|
||||
with pytest.raises(ValueError):
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=uninitialized_user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=1,
|
||||
)
|
||||
|
||||
|
||||
def test_joint_buy_product_uninitialized_user_in_list(sql_session: Session) -> None:
|
||||
user, _, _, product = insert_test_data(sql_session)
|
||||
|
||||
uninitialized_user = User("Uninitialized User")
|
||||
with pytest.raises(ValueError):
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, uninitialized_user],
|
||||
users=[user],
|
||||
product=product,
|
||||
product_count=1,
|
||||
)
|
||||
@@ -170,7 +146,6 @@ def test_joint_buy_product_out_of_stock(sql_session: Session) -> None:
|
||||
users=[user, user2, user3],
|
||||
product=product,
|
||||
product_count=10,
|
||||
time=transactions[-1].time + timedelta(days=1),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from datetime import datetime
|
||||
from pprint import pprint
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, User
|
||||
from dibbler.models.Transaction import Transaction
|
||||
from dibbler.queries import product_owners, product_owners_log, product_stock
|
||||
from tests.helpers import assign_times, assert_id_order_similar_to_time_order
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> tuple[Product, User]:
|
||||
@@ -21,17 +20,6 @@ def insert_test_data(sql_session: Session) -> tuple[Product, User]:
|
||||
return product, user
|
||||
|
||||
|
||||
def test_product_owners_unitilialized_product(sql_session: Session) -> None:
|
||||
user = User("testuser")
|
||||
sql_session.add(user)
|
||||
sql_session.commit()
|
||||
|
||||
product = Product("1234567890123", "Uninitialized Product")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
product_owners(sql_session, product)
|
||||
|
||||
|
||||
def test_product_owners_no_transactions(sql_session: Session) -> None:
|
||||
product, _ = insert_test_data(sql_session)
|
||||
|
||||
@@ -51,16 +39,12 @@ def test_product_owners_add_products(sql_session: Session) -> None:
|
||||
amount=30,
|
||||
per_product=10,
|
||||
product_count=3,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
)
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
@@ -76,21 +60,19 @@ def test_product_owners_add_and_buy_products(sql_session: Session) -> None:
|
||||
amount=30,
|
||||
per_product=10,
|
||||
product_count=3,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.buy_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=1,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
@@ -106,21 +88,19 @@ def test_product_owners_add_and_throw_products(sql_session: Session) -> None:
|
||||
amount=40,
|
||||
per_product=10,
|
||||
product_count=4,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.throw_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
@@ -139,6 +119,7 @@ def test_product_owners_multiple_users(sql_session: Session) -> None:
|
||||
amount=20,
|
||||
per_product=10,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user2.id,
|
||||
@@ -146,16 +127,13 @@ def test_product_owners_multiple_users(sql_session: Session) -> None:
|
||||
amount=30,
|
||||
per_product=10,
|
||||
product_count=3,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
@@ -172,21 +150,18 @@ def test_product_owners_adjust_stock_down(sql_session: Session) -> None:
|
||||
amount=50,
|
||||
per_product=10,
|
||||
product_count=5,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=-2,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
assert product_stock(sql_session, product) == 3
|
||||
@@ -205,21 +180,18 @@ def test_product_owners_adjust_stock_up(sql_session: Session) -> None:
|
||||
amount=20,
|
||||
per_product=10,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=3,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
@@ -236,21 +208,18 @@ def test_product_owners_negative_stock(sql_session: Session) -> None:
|
||||
amount=10,
|
||||
per_product=10,
|
||||
product_count=1,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.buy_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
assert owners == []
|
||||
|
||||
@@ -263,6 +232,7 @@ def test_product_owners_add_products_from_negative_stock(sql_session: Session) -
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user.id,
|
||||
@@ -270,16 +240,12 @@ def test_product_owners_add_products_from_negative_stock(sql_session: Session) -
|
||||
amount=30,
|
||||
per_product=10,
|
||||
product_count=3,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
@@ -299,6 +265,7 @@ def test_product_owners_interleaved_users(sql_session: Session) -> None:
|
||||
amount=20,
|
||||
per_product=10,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user2.id,
|
||||
@@ -306,11 +273,13 @@ def test_product_owners_interleaved_users(sql_session: Session) -> None:
|
||||
amount=30,
|
||||
per_product=10,
|
||||
product_count=3,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
Transaction.buy_product(
|
||||
user_id=user1.id,
|
||||
product_id=product.id,
|
||||
product_count=1,
|
||||
time=datetime(2024, 1, 3, 10, 0, 0),
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user1.id,
|
||||
@@ -318,16 +287,12 @@ def test_product_owners_interleaved_users(sql_session: Session) -> None:
|
||||
amount=10,
|
||||
per_product=10,
|
||||
product_count=1,
|
||||
time=datetime(2024, 1, 4, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_owners_log(sql_session, product))
|
||||
|
||||
owners = product_owners(sql_session, product)
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from pprint import pprint
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
from dibbler.queries import product_price, product_price_log, joint_buy_product
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
# 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
|
||||
@@ -24,17 +22,6 @@ def insert_test_data(sql_session: Session) -> tuple[User, Product]:
|
||||
return user, product
|
||||
|
||||
|
||||
def test_product_price_uninitialized_product(sql_session: Session) -> None:
|
||||
user = User("Test User")
|
||||
sql_session.add(user)
|
||||
sql_session.commit()
|
||||
|
||||
product = Product("1234567890123", "Uninitialized Product")
|
||||
|
||||
with pytest.raises(ValueError, match="Product must be persisted in the database."):
|
||||
product_price(sql_session, product)
|
||||
|
||||
|
||||
def test_product_price_no_transactions(sql_session: Session) -> None:
|
||||
_, product = insert_test_data(sql_session)
|
||||
|
||||
@@ -70,6 +57,7 @@ def test_product_price_sold_out(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
||||
amount=27 * 2 - 1,
|
||||
per_product=27,
|
||||
product_count=2,
|
||||
@@ -77,19 +65,16 @@ def test_product_price_sold_out(sql_session: Session) -> None:
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
assert product_price(sql_session, product) == 27
|
||||
@@ -100,10 +85,12 @@ def test_product_price_interest(sql_session: Session) -> None:
|
||||
|
||||
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,
|
||||
@@ -112,13 +99,9 @@ def test_product_price_interest(sql_session: Session) -> None:
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
product_price_ = product_price(sql_session, product)
|
||||
@@ -133,10 +116,12 @@ def test_product_price_changing_interest(sql_session: Session) -> None:
|
||||
|
||||
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,
|
||||
@@ -144,18 +129,15 @@ def test_product_price_changing_interest(sql_session: Session) -> None:
|
||||
product_id=product.id,
|
||||
),
|
||||
Transaction.adjust_interest(
|
||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
||||
interest_rate_percent=120,
|
||||
user_id=user.id,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
product_price_interest = product_price(sql_session, product, include_interest=True)
|
||||
@@ -167,6 +149,7 @@ def test_product_price_old_transaction(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
||||
amount=27 * 2,
|
||||
per_product=27,
|
||||
product_count=2,
|
||||
@@ -175,6 +158,7 @@ def test_product_price_old_transaction(sql_session: Session) -> None:
|
||||
),
|
||||
# Price should be 27
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
||||
amount=38 * 3,
|
||||
per_product=38,
|
||||
product_count=3,
|
||||
@@ -184,27 +168,23 @@ def test_product_price_old_transaction(sql_session: Session) -> None:
|
||||
# price should be averaged upwards
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
until_transaction = transactions[0]
|
||||
|
||||
pprint(
|
||||
product_price_log(
|
||||
sql_session,
|
||||
product,
|
||||
until_transaction=until_transaction,
|
||||
until=until_transaction,
|
||||
)
|
||||
)
|
||||
|
||||
product_price_ = product_price(
|
||||
sql_session,
|
||||
product,
|
||||
until_transaction=until_transaction,
|
||||
until=until_transaction,
|
||||
)
|
||||
assert product_price_ == 27
|
||||
|
||||
@@ -215,6 +195,7 @@ def test_product_price_round_up_from_below(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
||||
amount=27 * 2,
|
||||
per_product=27,
|
||||
product_count=2,
|
||||
@@ -223,6 +204,7 @@ def test_product_price_round_up_from_below(sql_session: Session) -> None:
|
||||
),
|
||||
# Price should be 27
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
||||
amount=38 * 3,
|
||||
per_product=38,
|
||||
product_count=3,
|
||||
@@ -232,13 +214,9 @@ def test_product_price_round_up_from_below(sql_session: Session) -> None:
|
||||
# price should be averaged upwards
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
product_price_ = product_price(sql_session, product)
|
||||
@@ -251,6 +229,7 @@ def test_product_price_round_up_from_above(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
||||
amount=27 * 2,
|
||||
per_product=27,
|
||||
product_count=2,
|
||||
@@ -259,6 +238,7 @@ def test_product_price_round_up_from_above(sql_session: Session) -> None:
|
||||
),
|
||||
# Price should be 27
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
||||
amount=20 * 3,
|
||||
per_product=20,
|
||||
product_count=3,
|
||||
@@ -268,13 +248,9 @@ def test_product_price_round_up_from_above(sql_session: Session) -> None:
|
||||
# price should be averaged downwards
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
product_price_ = product_price(sql_session, product)
|
||||
@@ -286,6 +262,7 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
||||
amount=1,
|
||||
per_product=10,
|
||||
product_count=1,
|
||||
@@ -293,11 +270,13 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
|
||||
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,
|
||||
@@ -306,13 +285,9 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
# Stock went subzero, price should be the last added product price
|
||||
@@ -320,11 +295,13 @@ def test_product_price_with_negative_stock_single_addition(sql_session: Session)
|
||||
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,
|
||||
@@ -332,11 +309,13 @@ def test_product_price_with_negative_stock_multiple_additions(sql_session: Sessi
|
||||
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,
|
||||
@@ -344,6 +323,7 @@ def test_product_price_with_negative_stock_multiple_additions(sql_session: Sessi
|
||||
product_id=product.id,
|
||||
),
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 13, 0, 3),
|
||||
amount=29,
|
||||
per_product=29,
|
||||
product_count=2,
|
||||
@@ -352,18 +332,14 @@ def test_product_price_with_negative_stock_multiple_additions(sql_session: Sessi
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
|
||||
# Stock went subzero, price should be the last added product price
|
||||
# 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(29)
|
||||
assert product1_price == math.ceil((22 + 29 * 2) / (1 + 2))
|
||||
|
||||
|
||||
def test_product_price_joint_transactions(sql_session: Session) -> None:
|
||||
@@ -374,6 +350,7 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
||||
amount=30 * 3,
|
||||
per_product=30,
|
||||
product_count=3,
|
||||
@@ -381,6 +358,7 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
|
||||
product_id=product.id,
|
||||
),
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
||||
amount=20 * 2,
|
||||
per_product=20,
|
||||
product_count=2,
|
||||
@@ -389,23 +367,19 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
product_price_ = product_price(sql_session, product)
|
||||
assert product_price_ == math.ceil((30 * 3 + 20 * 2) / (3 + 2))
|
||||
|
||||
transactions += joint_buy_product(
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
||||
instigator=user1,
|
||||
users=[user1, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
pprint(product_price_log(sql_session, product))
|
||||
@@ -418,12 +392,12 @@ def test_product_price_joint_transactions(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 3),
|
||||
amount=25 * 4,
|
||||
per_product=25,
|
||||
product_count=4,
|
||||
user_id=user1.id,
|
||||
product_id=product.id,
|
||||
time=transactions[-1].time + timedelta(seconds=1),
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
from dibbler.models.TransactionType import TransactionTypeSQL
|
||||
from dibbler.queries import joint_buy_product, product_stock
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> tuple[User, Product]:
|
||||
@@ -17,37 +18,6 @@ def insert_test_data(sql_session: Session) -> tuple[User, Product]:
|
||||
return user, product
|
||||
|
||||
|
||||
def test_product_stock_uninitialized_product(sql_session: Session) -> None:
|
||||
user = User("Test User 1")
|
||||
sql_session.add(user)
|
||||
sql_session.commit()
|
||||
|
||||
product = Product("1234567890123", "Uninitialized Product")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
product_stock(sql_session, product)
|
||||
|
||||
|
||||
def test_product_stock_until_datetime_and_transaction_id_not_allowed(sql_session: Session) -> None:
|
||||
user, product = insert_test_data(sql_session)
|
||||
|
||||
transaction = Transaction.add_product(
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=1,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
product_stock(
|
||||
sql_session,
|
||||
product,
|
||||
until_time=datetime.now(),
|
||||
until_transaction=transaction,
|
||||
)
|
||||
|
||||
|
||||
def test_product_stock_basic_history(sql_session: Session) -> None:
|
||||
user, product = insert_test_data(sql_session)
|
||||
|
||||
@@ -55,6 +25,7 @@ def test_product_stock_basic_history(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user.id,
|
||||
@@ -79,21 +50,18 @@ def test_product_stock_adjust_stock_up(sql_session: Session) -> None:
|
||||
amount=50,
|
||||
per_product=10,
|
||||
product_count=5,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=2,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
assert product_stock(sql_session, product) == 5 + 2
|
||||
|
||||
|
||||
@@ -107,21 +75,18 @@ def test_product_stock_adjust_stock_down(sql_session: Session) -> None:
|
||||
amount=50,
|
||||
per_product=10,
|
||||
product_count=5,
|
||||
time=datetime(2024, 1, 1, 10, 0, 0),
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=-2,
|
||||
time=datetime(2024, 1, 2, 10, 0, 0),
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
assert product_stock(sql_session, product) == 5 - 2
|
||||
|
||||
|
||||
@@ -130,6 +95,7 @@ def test_product_stock_complex_history(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 13, 0, 0),
|
||||
amount=27 * 2,
|
||||
per_product=27,
|
||||
user_id=user.id,
|
||||
@@ -137,11 +103,13 @@ def test_product_stock_complex_history(sql_session: Session) -> None:
|
||||
product_count=2,
|
||||
),
|
||||
Transaction.buy_product(
|
||||
time=datetime(2023, 10, 1, 13, 0, 1),
|
||||
user_id=user.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=user.id,
|
||||
@@ -149,29 +117,28 @@ def test_product_stock_complex_history(sql_session: Session) -> None:
|
||||
product_count=4,
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
time=datetime(2023, 10, 1, 15, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=3,
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
time=datetime(2023, 10, 1, 15, 0, 1),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=-2,
|
||||
),
|
||||
Transaction.throw_product(
|
||||
time=datetime(2023, 10, 1, 15, 0, 2),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=1,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
assert product_stock(sql_session, product) == 2 - 3 + 4 + 3 - 2 - 1
|
||||
|
||||
|
||||
@@ -186,6 +153,7 @@ def test_negative_product_stock(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 14, 0, 0),
|
||||
amount=50,
|
||||
per_product=50,
|
||||
user_id=user.id,
|
||||
@@ -193,24 +161,22 @@ def test_negative_product_stock(sql_session: Session) -> None:
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.buy_product(
|
||||
time=datetime(2023, 10, 1, 14, 0, 1),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=2,
|
||||
),
|
||||
Transaction.adjust_stock(
|
||||
time=datetime(2023, 10, 1, 16, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
product_count=-1,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
# The stock should be negative because we added and bought the product
|
||||
assert product_stock(sql_session, product) == 1 - 2 - 1
|
||||
|
||||
@@ -238,7 +204,7 @@ def test_product_stock_joint_transaction(sql_session: Session) -> None:
|
||||
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
time=transactions[0].time + timedelta(seconds=1),
|
||||
time=datetime(2023, 10, 1, 17, 0, 1),
|
||||
instigator=user1,
|
||||
users=[user1, user2],
|
||||
product=product,
|
||||
@@ -248,11 +214,12 @@ def test_product_stock_joint_transaction(sql_session: Session) -> None:
|
||||
assert product_stock(sql_session, product) == 5 - 3
|
||||
|
||||
|
||||
def test_product_stock_until_time(sql_session: Session) -> None:
|
||||
def test_product_stock_until(sql_session: Session) -> None:
|
||||
user, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user.id,
|
||||
@@ -260,6 +227,7 @@ def test_product_stock_until_time(sql_session: Session) -> None:
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 2, 12, 0, 0),
|
||||
amount=20,
|
||||
per_product=10,
|
||||
user_id=user.id,
|
||||
@@ -267,19 +235,7 @@ def test_product_stock_until_time(sql_session: Session) -> None:
|
||||
product_count=2,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
assert (
|
||||
product_stock(
|
||||
sql_session,
|
||||
product,
|
||||
until_time=transactions[-1].time - timedelta(seconds=1),
|
||||
)
|
||||
== 1
|
||||
)
|
||||
assert product_stock(sql_session, product, until=datetime(2023, 10, 1, 23, 59, 59)) == 1
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product
|
||||
@@ -19,13 +18,6 @@ def insert_test_data(sql_session: Session) -> list[Product]:
|
||||
return products
|
||||
|
||||
|
||||
def test_search_product_empty_not_allowed(sql_session: Session) -> None:
|
||||
insert_test_data(sql_session)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
search_product("", sql_session)
|
||||
|
||||
|
||||
def test_search_product_no_products(sql_session: Session) -> None:
|
||||
result = search_product("Nonexistent Product", sql_session)
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from sqlalchemy.orm import Session
|
||||
import pytest
|
||||
|
||||
from dibbler.models import User
|
||||
from dibbler.queries import search_user
|
||||
@@ -24,13 +23,6 @@ def setup_users(sql_session: Session) -> None:
|
||||
sql_session.commit()
|
||||
|
||||
|
||||
def test_search_user_empty_not_allowed(sql_session: Session) -> None:
|
||||
setup_users(sql_session)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
search_user("", sql_session)
|
||||
|
||||
|
||||
def test_search_user_exact_match(sql_session: Session) -> None:
|
||||
setup_users(sql_session)
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ from dibbler.models import (
|
||||
User,
|
||||
)
|
||||
from dibbler.queries import transaction_log
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> tuple[User, User, Product, Product]:
|
||||
@@ -34,18 +33,22 @@ def insert_default_test_transactions(
|
||||
) -> list[Transaction]:
|
||||
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,
|
||||
@@ -53,11 +56,13 @@ def insert_default_test_transactions(
|
||||
product_id=product1.id,
|
||||
),
|
||||
Transaction.buy_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 1),
|
||||
product_count=1,
|
||||
user_id=user2.id,
|
||||
product_id=product2.id,
|
||||
),
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 12, 0, 2),
|
||||
amount=15 * 1,
|
||||
per_product=15,
|
||||
product_count=1,
|
||||
@@ -65,127 +70,19 @@ def insert_default_test_transactions(
|
||||
product_id=product2.id,
|
||||
),
|
||||
Transaction.transfer(
|
||||
time=datetime(2023, 10, 1, 14, 0, 0),
|
||||
amount=30,
|
||||
user_id=user1.id,
|
||||
transfer_user_id=user2.id,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
return transactions
|
||||
|
||||
|
||||
def test_transaction_log_invalid_limit(sql_session: Session) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(sql_session, limit=0)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(sql_session, limit=-1)
|
||||
|
||||
|
||||
def test_transaction_log_uninitialized_user(sql_session: Session) -> None:
|
||||
user = User("Uninitialized User")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(sql_session, user=user)
|
||||
|
||||
|
||||
def test_transaction_log_uninitialized_product(sql_session: Session) -> None:
|
||||
product = Product("1234567890123", "Uninitialized Product")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(sql_session, product=product)
|
||||
|
||||
|
||||
def test_transaction_log_uninitialized_after_until_transaction(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
uninitialized_transaction = Transaction.adjust_balance(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
amount=100,
|
||||
user_id=user.id,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
after_transaction=uninitialized_transaction,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
until_transaction=uninitialized_transaction,
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_product_and_user_not_allowed(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
product=product,
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_datetime_and_transaction_id_not_allowed(
|
||||
sql_session: Session,
|
||||
) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
trx = Transaction.adjust_balance(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
amount=100,
|
||||
user_id=user.id,
|
||||
)
|
||||
sql_session.add(trx)
|
||||
sql_session.commit()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
until_time=datetime(2023, 10, 1, 11, 0, 0),
|
||||
until_transaction=trx,
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_datetime_and_transaction_id_not_allowed(
|
||||
sql_session: Session,
|
||||
) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
trx = Transaction.adjust_balance(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
amount=100,
|
||||
user_id=user.id,
|
||||
)
|
||||
sql_session.add(trx)
|
||||
sql_session.commit()
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
after_time=datetime(2023, 10, 1, 15, 0, 0),
|
||||
after_transaction=trx,
|
||||
)
|
||||
|
||||
|
||||
def test_user_transactions_no_transactions(sql_session: Session) -> None:
|
||||
insert_test_data(sql_session)
|
||||
|
||||
@@ -194,13 +91,6 @@ def test_user_transactions_no_transactions(sql_session: Session) -> None:
|
||||
assert len(transactions) == 0
|
||||
|
||||
|
||||
def test_transaction_log_basic(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
assert len(transaction_log(sql_session)) == 7
|
||||
|
||||
|
||||
def test_transaction_log_filtered_by_user(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
@@ -255,7 +145,7 @@ def test_transaction_log_after_datetime_exclusive(sql_session: Session) -> None:
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_time=transactions[2].time,
|
||||
after_inclusive=False,
|
||||
exclusive_after=True,
|
||||
)
|
||||
)
|
||||
== len(transactions) - 3
|
||||
@@ -271,7 +161,7 @@ def test_transaction_log_after_transaction_id(sql_session: Session) -> None:
|
||||
assert len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction=first_transaction,
|
||||
after_transaction_id=first_transaction.id,
|
||||
)
|
||||
) == len(transactions)
|
||||
|
||||
@@ -286,7 +176,7 @@ def test_transaction_log_after_transaction_id_one_transaction(sql_session: Sessi
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction=last_transaction,
|
||||
after_transaction_id=last_transaction.id,
|
||||
)
|
||||
)
|
||||
== 1
|
||||
@@ -303,15 +193,15 @@ def test_transaction_log_after_transaction_id_exclusive(sql_session: Session) ->
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction=third_transaction,
|
||||
after_inclusive=False,
|
||||
after_transaction_id=third_transaction.id,
|
||||
exclusive_after=True,
|
||||
)
|
||||
)
|
||||
== len(transactions) - 3
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_datetime(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_datetime(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -319,14 +209,14 @@ def test_transaction_log_until_datetime(sql_session: Session) -> None:
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
until_time=transactions[-3].time,
|
||||
before_time=transactions[-3].time,
|
||||
)
|
||||
)
|
||||
== len(transactions) - 2
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_datetime_no_transactions(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_datetime_no_transactions(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -334,14 +224,14 @@ def test_transaction_log_until_datetime_no_transactions(sql_session: Session) ->
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
until_time=transactions[0].time - timedelta(seconds=1),
|
||||
before_time=transactions[0].time - timedelta(seconds=1),
|
||||
)
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_datetime_exclusive(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_datetime_exclusive(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -349,15 +239,15 @@ def test_transaction_log_until_datetime_exclusive(sql_session: Session) -> None:
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
until_time=transactions[-3].time,
|
||||
until_inclusive=False,
|
||||
before_time=transactions[-3].time,
|
||||
exclusive_before=True,
|
||||
)
|
||||
)
|
||||
== len(transactions) - 3
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_transaction(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_transaction_id(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -367,14 +257,14 @@ def test_transaction_log_until_transaction(sql_session: Session) -> None:
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
until_transaction=last_transaction,
|
||||
before_transaction_id=last_transaction.id,
|
||||
)
|
||||
)
|
||||
== len(transactions) - 2
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_transaction_one_transaction(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_transaction_id_one_transaction(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -384,14 +274,14 @@ def test_transaction_log_until_transaction_one_transaction(sql_session: Session)
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
until_transaction=first_transaction,
|
||||
before_transaction_id=first_transaction.id,
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_until_transaction_exclusive(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_transaction_id_exclusive(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -401,30 +291,15 @@ def test_transaction_log_until_transaction_exclusive(sql_session: Session) -> No
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
until_transaction=last_transaction,
|
||||
until_inclusive=False,
|
||||
before_transaction_id=last_transaction.id,
|
||||
exclusive_before=True,
|
||||
)
|
||||
)
|
||||
== len(transactions) - 3
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_until_datetime_illegal_order(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_time=fifth_transaction.time,
|
||||
until_time=second_transaction.time,
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_until_datetime_combined(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_after_datetime_combined(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
@@ -436,83 +311,96 @@ def test_transaction_log_after_until_datetime_combined(sql_session: Session) ->
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_time=second_transaction.time,
|
||||
until_time=fifth_transaction.time,
|
||||
before_time=fifth_transaction.time,
|
||||
)
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_until_transaction_illegal_order(sql_session: Session) -> None:
|
||||
def test_transaction_log_before_after_transaction_id_combined(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
assert (
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction_id=second_transaction.id,
|
||||
before_transaction_id=fifth_transaction.id,
|
||||
)
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_before_date_after_transaction_id(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
assert (
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
before_time=fifth_transaction.time,
|
||||
after_transaction_id=second_transaction.id,
|
||||
)
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_before_transaction_id_after_date(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
assert (
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
before_transaction_id=fifth_transaction.id,
|
||||
after_time=second_transaction.time,
|
||||
)
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_product_and_user_not_allowed(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction=fifth_transaction,
|
||||
until_transaction=second_transaction,
|
||||
user=user,
|
||||
product=product,
|
||||
after_time=datetime(2023, 10, 1, 11, 0, 0),
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_until_transaction_combined(sql_session: Session) -> None:
|
||||
def test_transaction_log_after_datetime_and_transaction_id_not_allowed(
|
||||
sql_session: Session,
|
||||
) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
assert (
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction=second_transaction,
|
||||
until_transaction=fifth_transaction,
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
after_time=datetime(2023, 10, 1, 11, 0, 0),
|
||||
after_transaction_id=1,
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_date_until_transaction(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
assert (
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_time=second_transaction.time,
|
||||
until_transaction=fifth_transaction,
|
||||
)
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_after_transaction_until_date(sql_session: Session) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
transactions = insert_default_test_transactions(sql_session, user, user2, product, product2)
|
||||
|
||||
second_transaction = transactions[1]
|
||||
fifth_transaction = transactions[4]
|
||||
|
||||
assert (
|
||||
len(
|
||||
transaction_log(
|
||||
sql_session,
|
||||
after_transaction=second_transaction,
|
||||
until_time=fifth_transaction.time,
|
||||
)
|
||||
)
|
||||
== 4
|
||||
)
|
||||
|
||||
|
||||
def test_transaction_log_limit(sql_session: Session) -> None:
|
||||
@@ -605,7 +493,7 @@ def test_transaction_log_combined_filter_user_datetime_transaction_type_limit(
|
||||
sql_session,
|
||||
user=user,
|
||||
after_time=second_transaction.time,
|
||||
until_time=sixth_transaction.time,
|
||||
before_time=sixth_transaction.time,
|
||||
transaction_type=[TransactionType.ADJUST_BALANCE, TransactionType.ADD_PRODUCT],
|
||||
limit=2,
|
||||
)
|
||||
@@ -613,7 +501,7 @@ def test_transaction_log_combined_filter_user_datetime_transaction_type_limit(
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_transaction_log_combined_filter_user_transaction_transaction_type_limit(
|
||||
def test_transaction_log_combined_filter_user_transaction_id_transaction_type_limit(
|
||||
sql_session: Session,
|
||||
) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
@@ -625,8 +513,8 @@ def test_transaction_log_combined_filter_user_transaction_transaction_type_limit
|
||||
result = transaction_log(
|
||||
sql_session,
|
||||
user=user,
|
||||
after_transaction=second_transaction,
|
||||
until_transaction=sixth_transaction,
|
||||
after_transaction_id=second_transaction.id,
|
||||
before_transaction_id=sixth_transaction.id,
|
||||
transaction_type=[TransactionType.ADJUST_BALANCE, TransactionType.ADD_PRODUCT],
|
||||
limit=2,
|
||||
)
|
||||
@@ -647,7 +535,7 @@ def test_transaction_log_combined_filter_product_datetime_transaction_type_limit
|
||||
sql_session,
|
||||
product=product2,
|
||||
after_time=second_transaction.time,
|
||||
until_time=sixth_transaction.time,
|
||||
before_time=sixth_transaction.time,
|
||||
transaction_type=[TransactionType.BUY_PRODUCT, TransactionType.ADD_PRODUCT],
|
||||
limit=2,
|
||||
)
|
||||
@@ -655,7 +543,7 @@ def test_transaction_log_combined_filter_product_datetime_transaction_type_limit
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
def test_transaction_log_combined_filter_product_transaction_transaction_type_limit(
|
||||
def test_transaction_log_combined_filter_product_transaction_id_transaction_type_limit(
|
||||
sql_session: Session,
|
||||
) -> None:
|
||||
user, user2, product, product2 = insert_test_data(sql_session)
|
||||
@@ -667,8 +555,8 @@ def test_transaction_log_combined_filter_product_transaction_transaction_type_li
|
||||
result = transaction_log(
|
||||
sql_session,
|
||||
product=product2,
|
||||
after_transaction=second_transaction,
|
||||
until_transaction=sixth_transaction,
|
||||
after_transaction_id=second_transaction.id,
|
||||
before_transaction_id=sixth_transaction.id,
|
||||
transaction_type=[TransactionType.BUY_PRODUCT, TransactionType.ADD_PRODUCT],
|
||||
limit=2,
|
||||
)
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, ProductCache, Transaction, User, UserCache
|
||||
from dibbler.models.LastCacheTransaction import LastCacheTransaction
|
||||
from dibbler.queries import update_cache
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
|
||||
def insert_test_data(sql_session: Session) -> tuple[User, User, Product, Product]:
|
||||
user1 = User("Test User")
|
||||
user2 = User("Another User")
|
||||
product1 = Product("1234567890123", "Test Product 1")
|
||||
product2 = Product("9876543210987", "Test Product 2")
|
||||
|
||||
sql_session.add_all([user1, user2, product1, product2])
|
||||
sql_session.commit()
|
||||
|
||||
return user1, user2, product1, product2
|
||||
|
||||
|
||||
def get_cache_entries(sql_session: Session) -> tuple[list[UserCache], list[ProductCache]]:
|
||||
user_cache = sql_session.scalars(
|
||||
select(UserCache)
|
||||
.join(LastCacheTransaction, UserCache.last_cache_transaction_id == LastCacheTransaction.id)
|
||||
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
|
||||
.order_by(Transaction.time.asc(), UserCache.user_id)
|
||||
).all()
|
||||
|
||||
product_cache = sql_session.scalars(
|
||||
select(ProductCache)
|
||||
.join(LastCacheTransaction, ProductCache.last_cache_transaction_id == LastCacheTransaction.id)
|
||||
.join(Transaction, LastCacheTransaction.transaction_id == Transaction.id)
|
||||
.order_by(Transaction.time.asc(), ProductCache.product_id)
|
||||
).all()
|
||||
|
||||
return list(user_cache), list(product_cache)
|
||||
|
||||
|
||||
def test_affected_update_cache_no_history(sql_session: Session) -> None:
|
||||
insert_test_data(sql_session)
|
||||
|
||||
update_cache(sql_session)
|
||||
|
||||
|
||||
def test_affected_update_cache_basic_history(sql_session: Session) -> None:
|
||||
user1, user2, product1, product2 = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user1.id,
|
||||
product_id=product1.id,
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.add_product(
|
||||
amount=20,
|
||||
per_product=10,
|
||||
user_id=user2.id,
|
||||
product_id=product2.id,
|
||||
product_count=2,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
update_cache(sql_session)
|
||||
|
||||
user_cache = sql_session.scalars(select(UserCache).order_by(UserCache.user_id)).all()
|
||||
product_cache = sql_session.scalars(
|
||||
select(ProductCache).order_by(ProductCache.product_id)
|
||||
).all()
|
||||
|
||||
assert user_cache[0].user_id == user1.id
|
||||
assert user_cache[0].balance == 10
|
||||
assert user_cache[1].user_id == user2.id
|
||||
assert user_cache[1].balance == 20
|
||||
|
||||
assert product_cache[0].product_id == product1.id
|
||||
assert product_cache[0].stock == 1
|
||||
assert product_cache[0].price == 10
|
||||
|
||||
assert product_cache[1].product_id == product2.id
|
||||
assert product_cache[1].stock == 2
|
||||
assert product_cache[1].price == 10
|
||||
|
||||
|
||||
def test_update_cache_multiple_times_no_changes(sql_session: Session) -> None:
|
||||
user1, user2, product1, product2 = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user1.id,
|
||||
product_id=product1.id,
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.add_product(
|
||||
amount=20,
|
||||
per_product=10,
|
||||
user_id=user2.id,
|
||||
product_id=product2.id,
|
||||
product_count=2,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
update_cache(sql_session)
|
||||
|
||||
update_cache(sql_session)
|
||||
|
||||
user_cache, product_cache = get_cache_entries(sql_session)
|
||||
|
||||
assert user_cache[0].user_id == user1.id
|
||||
assert user_cache[0].balance == 10
|
||||
assert user_cache[1].user_id == user2.id
|
||||
assert user_cache[1].balance == 20
|
||||
|
||||
|
||||
def test_update_cache_multiple_times(sql_session: Session) -> None:
|
||||
user1, user2, product1, product2 = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
amount=10,
|
||||
per_product=10,
|
||||
user_id=user1.id,
|
||||
product_id=product1.id,
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.add_product(
|
||||
amount=20,
|
||||
per_product=10,
|
||||
user_id=user2.id,
|
||||
product_id=product2.id,
|
||||
product_count=2,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
update_cache(sql_session)
|
||||
|
||||
transactions_more = [
|
||||
Transaction.add_product(
|
||||
amount=30,
|
||||
per_product=10,
|
||||
user_id=user1.id,
|
||||
product_id=product1.id,
|
||||
product_count=3,
|
||||
),
|
||||
Transaction.buy_product(
|
||||
user_id=user1.id,
|
||||
product_id=product1.id,
|
||||
product_count=1,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions_more, start_time=transactions[-1].time)
|
||||
|
||||
sql_session.add_all(transactions_more)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions_more)
|
||||
|
||||
update_cache(sql_session)
|
||||
|
||||
user_cache, product_cache = get_cache_entries(sql_session)
|
||||
|
||||
assert user_cache[0].user_id == user1.id
|
||||
assert user_cache[0].balance == 10
|
||||
assert user_cache[1].user_id == user2.id
|
||||
assert user_cache[1].balance == 20
|
||||
assert product_cache[0].product_id == product1.id
|
||||
assert product_cache[0].stock == 1
|
||||
assert product_cache[0].price == 10
|
||||
assert product_cache[1].product_id == product2.id
|
||||
assert product_cache[1].stock == 2
|
||||
assert product_cache[1].price == 10
|
||||
|
||||
assert user_cache[2].user_id == user1.id
|
||||
assert user_cache[2].balance == 10 + 30 - 10
|
||||
assert product_cache[2].product_id == product1.id
|
||||
assert product_cache[2].stock == 1 + 3 - 1
|
||||
assert product_cache[2].price == 10
|
||||
@@ -1,18 +1,13 @@
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
from pprint import pprint
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from dibbler.models import Product, Transaction, User
|
||||
from dibbler.models.Transaction import (
|
||||
DEFAULT_INTEREST_RATE_PERCENT,
|
||||
DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
)
|
||||
from dibbler.models.Transaction import DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE
|
||||
from dibbler.queries import joint_buy_product, user_balance, user_balance_log
|
||||
from dibbler.queries.user_balance import _joint_transaction_query, _non_joint_transaction_query
|
||||
from tests.helpers import assert_id_order_similar_to_time_order, assign_times
|
||||
|
||||
# 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
|
||||
@@ -30,189 +25,6 @@ def insert_test_data(sql_session: Session) -> tuple[User, User, User, Product]:
|
||||
return user, user2, user3, product
|
||||
|
||||
|
||||
# NOTE: see economics spec
|
||||
def _product_cost(
|
||||
per_product: int,
|
||||
product_count: int,
|
||||
interest_rate_percent: int = DEFAULT_INTEREST_RATE_PERCENT,
|
||||
apply_penalty: bool = False,
|
||||
penalty_multiplier_percent: int = DEFAULT_PENALTY_MULTIPLIER_PERCENT,
|
||||
joint_shares: int = 1,
|
||||
joint_total_shares: int = 1,
|
||||
) -> int:
|
||||
base_cost: float = per_product * product_count * joint_shares / joint_total_shares
|
||||
added_interest: float = base_cost * ((interest_rate_percent - 100) / 100)
|
||||
|
||||
penalty: float = 0.0
|
||||
if apply_penalty:
|
||||
penalty: float = base_cost * ((penalty_multiplier_percent - 100) / 100)
|
||||
|
||||
total_cost: int = math.ceil(base_cost + added_interest + penalty)
|
||||
|
||||
return total_cost
|
||||
|
||||
|
||||
def test_non_joint_transaction_query(sql_session) -> None:
|
||||
user1, user2, user3, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.adjust_balance(
|
||||
user_id=user1.id,
|
||||
amount=100,
|
||||
),
|
||||
Transaction.adjust_balance(
|
||||
user_id=user2.id,
|
||||
amount=50,
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user2.id,
|
||||
amount=70,
|
||||
product_id=product.id,
|
||||
product_count=3,
|
||||
per_product=30,
|
||||
),
|
||||
Transaction.transfer(
|
||||
user_id=user1.id,
|
||||
transfer_user_id=user2.id,
|
||||
amount=50,
|
||||
),
|
||||
Transaction.transfer(
|
||||
user_id=user2.id,
|
||||
transfer_user_id=user3.id,
|
||||
amount=30,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
t = transactions
|
||||
|
||||
result = {
|
||||
row[0]
|
||||
for row in sql_session.execute(
|
||||
_non_joint_transaction_query(
|
||||
user_id=user1.id,
|
||||
use_cache=False,
|
||||
),
|
||||
).all()
|
||||
}
|
||||
assert result == {t[0].id, t[3].id}
|
||||
|
||||
result = {
|
||||
row[0]
|
||||
for row in sql_session.execute(
|
||||
_non_joint_transaction_query(
|
||||
user_id=user2.id,
|
||||
use_cache=False,
|
||||
),
|
||||
).all()
|
||||
}
|
||||
assert result == {
|
||||
t[1].id,
|
||||
t[2].id,
|
||||
t[3].id,
|
||||
t[4].id,
|
||||
}
|
||||
|
||||
result = {
|
||||
row[0]
|
||||
for row in sql_session.execute(
|
||||
_non_joint_transaction_query(
|
||||
user_id=user3.id,
|
||||
use_cache=False,
|
||||
),
|
||||
).all()
|
||||
}
|
||||
assert result == {t[4].id}
|
||||
|
||||
|
||||
def test_joint_transaction_query(sql_session: Session) -> None:
|
||||
user1, user2, user3, product = insert_test_data(sql_session)
|
||||
|
||||
j1 = joint_buy_product(
|
||||
sql_session,
|
||||
product=product,
|
||||
product_count=3,
|
||||
instigator=user1,
|
||||
users=[user1, user2],
|
||||
)
|
||||
|
||||
j2 = joint_buy_product(
|
||||
sql_session,
|
||||
product=product,
|
||||
product_count=2,
|
||||
instigator=user1,
|
||||
users=[user1, user1, user2],
|
||||
time=j1[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
j3 = joint_buy_product(
|
||||
sql_session,
|
||||
product=product,
|
||||
product_count=2,
|
||||
instigator=user1,
|
||||
users=[user1, user3, user3],
|
||||
time=j2[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
j4 = joint_buy_product(
|
||||
sql_session,
|
||||
product=product,
|
||||
product_count=2,
|
||||
instigator=user2,
|
||||
users=[user2, user3, user3],
|
||||
time=j3[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
assert_id_order_similar_to_time_order(j1 + j2 + j3 + j4)
|
||||
|
||||
result = set(
|
||||
sql_session.execute(
|
||||
_joint_transaction_query(
|
||||
user_id=user1.id,
|
||||
use_cache=False,
|
||||
),
|
||||
).all(),
|
||||
)
|
||||
assert result == {
|
||||
(j1[0].id, 1, 2),
|
||||
(j2[0].id, 2, 3),
|
||||
(j3[0].id, 1, 3),
|
||||
}
|
||||
|
||||
result = set(
|
||||
sql_session.execute(
|
||||
_joint_transaction_query(
|
||||
user_id=user2.id,
|
||||
use_cache=False,
|
||||
),
|
||||
).all(),
|
||||
)
|
||||
assert result == {
|
||||
(j1[0].id, 1, 2),
|
||||
(j2[0].id, 1, 3),
|
||||
(j4[0].id, 1, 3),
|
||||
}
|
||||
|
||||
result = set(
|
||||
sql_session.execute(
|
||||
_joint_transaction_query(
|
||||
user_id=user3.id,
|
||||
use_cache=False,
|
||||
),
|
||||
).all(),
|
||||
)
|
||||
assert result == {
|
||||
(j3[0].id, 2, 3),
|
||||
(j4[0].id, 2, 3),
|
||||
}
|
||||
|
||||
|
||||
def test_user_balance_no_transactions(sql_session: Session) -> None:
|
||||
user, *_ = insert_test_data(sql_session)
|
||||
|
||||
@@ -228,10 +40,12 @@ def test_user_balance_basic_history(sql_session: Session) -> None:
|
||||
|
||||
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,
|
||||
@@ -240,13 +54,9 @@ def test_user_balance_basic_history(sql_session: Session) -> None:
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
balance = user_balance(sql_session, user)
|
||||
@@ -255,32 +65,31 @@ def test_user_balance_basic_history(sql_session: Session) -> None:
|
||||
|
||||
|
||||
def test_user_balance_with_transfers(sql_session: Session) -> None:
|
||||
user1, user2, _, _ = insert_test_data(sql_session)
|
||||
user1, user2, _, product = insert_test_data(sql_session)
|
||||
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user1))
|
||||
|
||||
user1_balance = user_balance(sql_session, user1)
|
||||
@@ -292,11 +101,16 @@ def test_user_balance_with_transfers(sql_session: Session) -> None:
|
||||
assert user2_balance == 50 - 30
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Not yet implemented")
|
||||
def test_user_balance_complex_history(sql_session: Session) -> None: ...
|
||||
|
||||
|
||||
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,
|
||||
@@ -304,27 +118,26 @@ def test_user_balance_penalty(sql_session: Session) -> None:
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == 27 - 200 - _product_cost(27, 1, apply_penalty=True)
|
||||
assert user_balance(sql_session, user) == 27 - 200 - (
|
||||
27 * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100)
|
||||
)
|
||||
|
||||
|
||||
def test_user_balance_changing_penalty(sql_session: Session) -> None:
|
||||
@@ -332,6 +145,7 @@ def test_user_balance_changing_penalty(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27,
|
||||
@@ -339,43 +153,40 @@ def test_user_balance_changing_penalty(sql_session: Session) -> None:
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
27
|
||||
- 200
|
||||
- _product_cost(27, 1, apply_penalty=True)
|
||||
- _product_cost(27, 1, apply_penalty=True, penalty_multiplier_percent=300)
|
||||
)
|
||||
assert user_balance(sql_session, user) == 27 - 200 - (
|
||||
27 * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100)
|
||||
) - (27 * 3)
|
||||
|
||||
|
||||
def test_user_balance_interest(sql_session: Session) -> None:
|
||||
@@ -383,6 +194,7 @@ def test_user_balance_interest(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27,
|
||||
@@ -390,26 +202,23 @@ def test_user_balance_interest(sql_session: Session) -> None:
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == 27 - _product_cost(27, 1, interest_rate_percent=110)
|
||||
assert user_balance(sql_session, user) == 27 - math.ceil(27 * 1.1)
|
||||
|
||||
|
||||
def test_user_balance_changing_interest(sql_session: Session) -> None:
|
||||
@@ -417,6 +226,7 @@ def test_user_balance_changing_interest(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27 * 3,
|
||||
@@ -424,41 +234,36 @@ def test_user_balance_changing_interest(sql_session: Session) -> None:
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
27 * 3
|
||||
- _product_cost(27, 1, interest_rate_percent=110)
|
||||
- _product_cost(27, 1, interest_rate_percent=120)
|
||||
)
|
||||
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:
|
||||
@@ -466,6 +271,7 @@ def test_user_balance_penalty_interest_combined(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27,
|
||||
@@ -473,40 +279,31 @@ def test_user_balance_penalty_interest_combined(sql_session: Session) -> None:
|
||||
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,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
27
|
||||
- 200
|
||||
- _product_cost(
|
||||
27,
|
||||
1,
|
||||
interest_rate_percent=110,
|
||||
apply_penalty=True,
|
||||
)
|
||||
27 - 200 - math.ceil(27 * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100) * 1.1)
|
||||
)
|
||||
|
||||
|
||||
@@ -532,20 +329,11 @@ def test_user_balance_joint_transaction_single_user(sql_session: Session) -> Non
|
||||
users=[user],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=1,
|
||||
)
|
||||
)
|
||||
assert user_balance(sql_session, user) == (27 * 3) - (27 * 2)
|
||||
|
||||
|
||||
def test_user_balance_joint_transactions_multiple_users(sql_session: Session) -> None:
|
||||
@@ -570,20 +358,11 @@ def test_user_balance_joint_transactions_multiple_users(sql_session: Session) ->
|
||||
users=[user, user2, user3],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=3,
|
||||
)
|
||||
)
|
||||
assert user_balance(sql_session, user) == (27 * 3) - math.ceil((27 * 2) / 3)
|
||||
|
||||
|
||||
def test_user_balance_joint_transactions_multiple_times_self(sql_session: Session) -> None:
|
||||
@@ -608,20 +387,11 @@ def test_user_balance_joint_transactions_multiple_times_self(sql_session: Sessio
|
||||
users=[user, user, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=2,
|
||||
joint_total_shares=3,
|
||||
)
|
||||
)
|
||||
assert user_balance(sql_session, user) == (27 * 3) - math.ceil((27 * 2) * (2 / 3))
|
||||
|
||||
|
||||
def test_user_balance_joint_transactions_interest(sql_session: Session) -> None:
|
||||
@@ -629,6 +399,7 @@ def test_user_balance_joint_transactions_interest(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27 * 3,
|
||||
@@ -636,39 +407,25 @@ def test_user_balance_joint_transactions_interest(sql_session: Session) -> None:
|
||||
product_count=3,
|
||||
),
|
||||
Transaction.adjust_interest(
|
||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
||||
user_id=user.id,
|
||||
interest_rate_percent=110,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=1),
|
||||
)
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
interest_rate_percent=110,
|
||||
)
|
||||
)
|
||||
assert user_balance(sql_session, user) == (27 * 3) - math.ceil(math.ceil(27 * 2 / 2) * 1.1)
|
||||
|
||||
|
||||
def test_user_balance_joint_transactions_changing_interest(sql_session: Session) -> None:
|
||||
@@ -676,6 +433,7 @@ def test_user_balance_joint_transactions_changing_interest(sql_session: Session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27 * 4,
|
||||
@@ -684,29 +442,26 @@ def test_user_balance_joint_transactions_changing_interest(sql_session: Session)
|
||||
),
|
||||
# Pays 1.1x the price
|
||||
Transaction.adjust_interest(
|
||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
||||
user_id=user.id,
|
||||
interest_rate_percent=110,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
transactions += [
|
||||
transactions = [
|
||||
# Pays 1.2x the price
|
||||
Transaction.adjust_interest(
|
||||
time=transactions[-1].time + timedelta(minutes=15),
|
||||
time=datetime(2023, 10, 1, 12, 0, 0),
|
||||
user_id=user.id,
|
||||
interest_rate_percent=120,
|
||||
)
|
||||
@@ -714,35 +469,18 @@ def test_user_balance_joint_transactions_changing_interest(sql_session: Session)
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=1,
|
||||
time=transactions[-1].time + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 4)
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
interest_rate_percent=110,
|
||||
)
|
||||
- _product_cost(
|
||||
27,
|
||||
1,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
interest_rate_percent=120,
|
||||
)
|
||||
(27 * 4) - math.ceil(math.ceil(27 * 2 / 2) * 1.1) - math.ceil(math.ceil(27 * 1 / 2) * 1.2)
|
||||
)
|
||||
|
||||
|
||||
@@ -751,6 +489,7 @@ def test_user_balance_joint_transactions_penalty(sql_session: Session) -> None:
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
time=datetime(2023, 10, 1, 10, 0, 0),
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27 * 3,
|
||||
@@ -758,258 +497,43 @@ def test_user_balance_joint_transactions_penalty(sql_session: Session) -> None:
|
||||
product_count=3,
|
||||
),
|
||||
Transaction.adjust_balance(
|
||||
time=datetime(2023, 10, 1, 11, 0, 0),
|
||||
user_id=user.id,
|
||||
amount=-200,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- 200
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
apply_penalty=True,
|
||||
)
|
||||
- math.ceil(math.ceil(27 * 2 / 2) * (DEFAULT_PENALTY_MULTIPLIER_PERCENTAGE // 100))
|
||||
)
|
||||
|
||||
|
||||
def test_user_balance_joint_transactions_changing_penalty(sql_session: Session) -> None:
|
||||
user, user2, _, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27 * 3,
|
||||
per_product=27,
|
||||
product_count=3,
|
||||
),
|
||||
Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=-200,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
transactions += [
|
||||
Transaction.adjust_penalty(
|
||||
time=transactions[-1].time + timedelta(minutes=30),
|
||||
user_id=user.id,
|
||||
penalty_multiplier_percent=300,
|
||||
penalty_threshold=-100,
|
||||
)
|
||||
]
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=1,
|
||||
time=transactions[-1].time + timedelta(minutes=45),
|
||||
)
|
||||
|
||||
assert_id_order_similar_to_time_order(transactions)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- 200
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
apply_penalty=True,
|
||||
)
|
||||
- _product_cost(
|
||||
27,
|
||||
1,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
apply_penalty=True,
|
||||
penalty_multiplier_percent=300,
|
||||
)
|
||||
)
|
||||
@pytest.mark.skip(reason="Not yet implemented")
|
||||
def test_user_balance_joint_transactions_changing_penalty(sql_session: Session) -> None: ...
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Not yet implemented")
|
||||
def test_user_balance_joint_transactions_penalty_interest_combined(
|
||||
sql_session: Session,
|
||||
) -> None:
|
||||
user, user2, _, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.add_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27 * 3,
|
||||
per_product=27,
|
||||
product_count=3,
|
||||
),
|
||||
Transaction.adjust_interest(
|
||||
user_id=user.id,
|
||||
interest_rate_percent=110,
|
||||
),
|
||||
Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=-200,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
transactions += joint_buy_product(
|
||||
sql_session,
|
||||
instigator=user,
|
||||
users=[user, user2],
|
||||
product=product,
|
||||
product_count=2,
|
||||
time=transactions[-1].time + timedelta(minutes=15),
|
||||
)
|
||||
|
||||
pprint(user_balance_log(sql_session, user))
|
||||
|
||||
assert user_balance(sql_session, user) == (
|
||||
(27 * 3)
|
||||
- 200
|
||||
- _product_cost(
|
||||
27,
|
||||
2,
|
||||
joint_shares=1,
|
||||
joint_total_shares=2,
|
||||
interest_rate_percent=110,
|
||||
apply_penalty=True,
|
||||
)
|
||||
)
|
||||
) -> None: ...
|
||||
|
||||
|
||||
def test_user_balance_until_time(sql_session: Session) -> None:
|
||||
user, _, _, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=100,
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27,
|
||||
per_product=27,
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=50,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
pprint(
|
||||
user_balance_log(
|
||||
sql_session,
|
||||
user,
|
||||
until_time=transactions[1].time + timedelta(seconds=30),
|
||||
)
|
||||
)
|
||||
|
||||
balance = user_balance(
|
||||
sql_session,
|
||||
user,
|
||||
until_time=transactions[1].time + timedelta(seconds=30),
|
||||
)
|
||||
|
||||
assert balance == 100 + 27
|
||||
|
||||
|
||||
def test_user_balance_until_transaction(sql_session: Session) -> None:
|
||||
user, _, _, product = insert_test_data(sql_session)
|
||||
|
||||
transactions = [
|
||||
Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=100,
|
||||
),
|
||||
Transaction.add_product(
|
||||
user_id=user.id,
|
||||
product_id=product.id,
|
||||
amount=27,
|
||||
per_product=27,
|
||||
product_count=1,
|
||||
),
|
||||
Transaction.adjust_balance(
|
||||
user_id=user.id,
|
||||
amount=50,
|
||||
),
|
||||
]
|
||||
|
||||
assign_times(transactions)
|
||||
|
||||
sql_session.add_all(transactions)
|
||||
sql_session.commit()
|
||||
|
||||
until_transaction = transactions[1]
|
||||
|
||||
pprint(
|
||||
user_balance_log(
|
||||
sql_session,
|
||||
user,
|
||||
until_transaction=until_transaction,
|
||||
)
|
||||
)
|
||||
|
||||
balance = user_balance(
|
||||
sql_session,
|
||||
user,
|
||||
until_transaction=until_transaction,
|
||||
)
|
||||
|
||||
assert balance == 100 + 27
|
||||
@pytest.mark.skip(reason="Not yet implemented")
|
||||
def test_user_balance_until(sql_session: Session) -> None: ...
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="Not yet implemented")
|
||||
|
||||
295
uv.lock
generated
295
uv.lock
generated
@@ -133,89 +133,89 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.13.0"
|
||||
version = "7.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/45/2c665ca77ec32ad67e25c77daf1cee28ee4558f3bc571cdbaf88a00b9f23/coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936", size = 820905, upload-time = "2025-12-08T13:14:38.055Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/26/4a96807b193b011588099c3b5c89fbb05294e5b90e71018e065465f34eb6/coverage-7.12.0.tar.gz", hash = "sha256:fc11e0a4e372cb5f282f16ef90d4a585034050ccda536451901abfb19a57f40c", size = 819341, upload-time = "2025-11-18T13:34:20.766Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/dc/888bf90d8b1c3d0b4020a40e52b9f80957d75785931ec66c7dfaccc11c7d/coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820", size = 218104, upload-time = "2025-12-08T13:12:33.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/ea/069d51372ad9c380214e86717e40d1a743713a2af191cfba30a0911b0a4a/coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f", size = 218606, upload-time = "2025-12-08T13:12:34.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/09/77b1c3a66c2aa91141b6c4471af98e5b1ed9b9e6d17255da5eb7992299e3/coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96", size = 248999, upload-time = "2025-12-08T13:12:36.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/32/2e2f96e9d5691eaf1181d9040f850b8b7ce165ea10810fd8e2afa534cef7/coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259", size = 250925, upload-time = "2025-12-08T13:12:37.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/45/b88ddac1d7978859b9a39a8a50ab323186148f1d64bc068f86fc77706321/coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb", size = 253032, upload-time = "2025-12-08T13:12:38.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cb/e15513f94c69d4820a34b6bf3d2b1f9f8755fa6021be97c7065442d7d653/coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9", size = 249134, upload-time = "2025-12-08T13:12:40.382Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/61/d960ff7dc9e902af3310ce632a875aaa7860f36d2bc8fc8b37ee7c1b82a5/coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030", size = 250731, upload-time = "2025-12-08T13:12:41.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/34/c7c72821794afc7c7c2da1db8f00c2c98353078aa7fb6b5ff36aac834b52/coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833", size = 248795, upload-time = "2025-12-08T13:12:43.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5b/e0f07107987a43b2def9aa041c614ddb38064cbf294a71ef8c67d43a0cdd/coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8", size = 248514, upload-time = "2025-12-08T13:12:44.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/c2/c949c5d3b5e9fc6dd79e1b73cdb86a59ef14f3709b1d72bf7668ae12e000/coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753", size = 249424, upload-time = "2025-12-08T13:12:45.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f1/bbc009abd6537cec0dffb2cc08c17a7f03de74c970e6302db4342a6e05af/coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b", size = 220597, upload-time = "2025-12-08T13:12:47.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f6/d9977f2fb51c10fbaed0718ce3d0a8541185290b981f73b1d27276c12d91/coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe", size = 221536, upload-time = "2025-12-08T13:12:48.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ad/3fcf43fd96fb43e337a3073dea63ff148dcc5c41ba7a14d4c7d34efb2216/coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7", size = 220206, upload-time = "2025-12-08T13:12:50.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f1/2619559f17f31ba00fc40908efd1fbf1d0a5536eb75dc8341e7d660a08de/coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf", size = 218274, upload-time = "2025-12-08T13:12:52.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/11/30d71ae5d6e949ff93b2a79a2c1b4822e00423116c5c6edfaeef37301396/coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f", size = 218638, upload-time = "2025-12-08T13:12:53.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/c2/fce80fc6ded8d77e53207489d6065d0fed75db8951457f9213776615e0f5/coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb", size = 250129, upload-time = "2025-12-08T13:12:54.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/b6/51b5d1eb6fcbb9a1d5d6984e26cbe09018475c2922d554fd724dd0f056ee/coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621", size = 252885, upload-time = "2025-12-08T13:12:56.401Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/972a5affea41de798691ab15d023d3530f9f56a72e12e243f35031846ff7/coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74", size = 253974, upload-time = "2025-12-08T13:12:57.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/56/116513aee860b2c7968aa3506b0f59b22a959261d1dbf3aea7b4450a7520/coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57", size = 250538, upload-time = "2025-12-08T13:12:59.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/75/074476d64248fbadf16dfafbf93fdcede389ec821f74ca858d7c87d2a98c/coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8", size = 251912, upload-time = "2025-12-08T13:13:00.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d2/aa4f8acd1f7c06024705c12609d8698c51b27e4d635d717cd1934c9668e2/coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d", size = 250054, upload-time = "2025-12-08T13:13:01.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/98/8df9e1af6a493b03694a1e8070e024e7d2cdc77adedc225a35e616d505de/coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b", size = 249619, upload-time = "2025-12-08T13:13:03.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/71/f8679231f3353018ca66ef647fa6fe7b77e6bff7845be54ab84f86233363/coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd", size = 251496, upload-time = "2025-12-08T13:13:04.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/86/9cb406388034eaf3c606c22094edbbb82eea1fa9d20c0e9efadff20d0733/coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef", size = 220808, upload-time = "2025-12-08T13:13:06.422Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/59/af483673df6455795daf5f447c2f81a3d2fcfc893a22b8ace983791f6f34/coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae", size = 221616, upload-time = "2025-12-08T13:13:07.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b0/959d582572b30a6830398c60dd419c1965ca4b5fb38ac6b7093a0d50ca8d/coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080", size = 220261, upload-time = "2025-12-08T13:13:09.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/cc/bce226595eb3bf7d13ccffe154c3c487a22222d87ff018525ab4dd2e9542/coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf", size = 218297, upload-time = "2025-12-08T13:13:10.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/9f/73c4d34600aae03447dff3d7ad1d0ac649856bfb87d1ca7d681cfc913f9e/coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a", size = 218673, upload-time = "2025-12-08T13:13:12.562Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ab/8fa097db361a1e8586535ae5073559e6229596b3489ec3ef2f5b38df8cb2/coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74", size = 249652, upload-time = "2025-12-08T13:13:13.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/3a/9bfd4de2ff191feb37ef9465855ca56a6f2f30a3bca172e474130731ac3d/coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6", size = 252251, upload-time = "2025-12-08T13:13:15.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/61/b5d8105f016e1b5874af0d7c67542da780ccd4a5f2244a433d3e20ceb1ad/coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b", size = 253492, upload-time = "2025-12-08T13:13:16.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/b8/0fad449981803cc47a4694768b99823fb23632150743f9c83af329bb6090/coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232", size = 249850, upload-time = "2025-12-08T13:13:18.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/e9/8d68337c3125014d918cf4327d5257553a710a2995a6a6de2ac77e5aa429/coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971", size = 251633, upload-time = "2025-12-08T13:13:19.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/14/d4112ab26b3a1bc4b3c1295d8452dcf399ed25be4cf649002fb3e64b2d93/coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d", size = 249586, upload-time = "2025-12-08T13:13:20.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/a9/22b0000186db663b0d82f86c2f1028099ae9ac202491685051e2a11a5218/coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137", size = 249412, upload-time = "2025-12-08T13:13:22.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/2e/42d8e0d9e7527fba439acdc6ed24a2b97613b1dc85849b1dd935c2cffef0/coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511", size = 251191, upload-time = "2025-12-08T13:13:23.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/af/8c7af92b1377fd8860536aadd58745119252aaaa71a5213e5a8e8007a9f5/coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1", size = 220829, upload-time = "2025-12-08T13:13:25.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/f9/725e8bf16f343d33cbe076c75dc8370262e194ff10072c0608b8e5cf33a3/coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a", size = 221640, upload-time = "2025-12-08T13:13:26.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/ff/e98311000aa6933cc79274e2b6b94a2fe0fe3434fca778eba82003675496/coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6", size = 220269, upload-time = "2025-12-08T13:13:28.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/cf/bbaa2e1275b300343ea865f7d424cc0a2e2a1df6925a070b2b2d5d765330/coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a", size = 218990, upload-time = "2025-12-08T13:13:29.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/1d/82f0b3323b3d149d7672e7744c116e9c170f4957e0c42572f0366dbb4477/coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8", size = 219340, upload-time = "2025-12-08T13:13:31.524Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/e3/fe3fd4702a3832a255f4d43013eacb0ef5fc155a5960ea9269d8696db28b/coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053", size = 260638, upload-time = "2025-12-08T13:13:32.965Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/01/63186cb000307f2b4da463f72af9b85d380236965574c78e7e27680a2593/coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071", size = 262705, upload-time = "2025-12-08T13:13:34.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/a1/c0dacef0cc865f2455d59eed3548573ce47ed603205ffd0735d1d78b5906/coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e", size = 265125, upload-time = "2025-12-08T13:13:35.73Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/92/82b99223628b61300bd382c205795533bed021505eab6dd86e11fb5d7925/coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493", size = 259844, upload-time = "2025-12-08T13:13:37.69Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/2c/89b0291ae4e6cd59ef042708e1c438e2290f8c31959a20055d8768349ee2/coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0", size = 262700, upload-time = "2025-12-08T13:13:39.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/f9/a5f992efae1996245e796bae34ceb942b05db275e4b34222a9a40b9fbd3b/coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e", size = 260321, upload-time = "2025-12-08T13:13:41.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/89/a29f5d98c64fedbe32e2ac3c227fbf78edc01cc7572eee17d61024d89889/coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c", size = 259222, upload-time = "2025-12-08T13:13:43.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/c3/940fe447aae302a6701ee51e53af7e08b86ff6eed7631e5740c157ee22b9/coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e", size = 261411, upload-time = "2025-12-08T13:13:44.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/31/12a4aec689cb942a89129587860ed4d0fd522d5fda81237147fde554b8ae/coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46", size = 221505, upload-time = "2025-12-08T13:13:46.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/8c/3b5fe3259d863572d2b0827642c50c3855d26b3aefe80bdc9eba1f0af3b0/coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39", size = 222569, upload-time = "2025-12-08T13:13:47.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/39/f71fa8316a96ac72fc3908839df651e8eccee650001a17f2c78cdb355624/coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e", size = 220841, upload-time = "2025-12-08T13:13:49.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4b/9b54bedda55421449811dcd5263a2798a63f48896c24dfb92b0f1b0845bd/coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256", size = 218343, upload-time = "2025-12-08T13:13:50.811Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/df/c3a1f34d4bba2e592c8979f924da4d3d4598b0df2392fbddb7761258e3dc/coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a", size = 218672, upload-time = "2025-12-08T13:13:52.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/62/eec0659e47857698645ff4e6ad02e30186eb8afd65214fd43f02a76537cb/coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9", size = 249715, upload-time = "2025-12-08T13:13:53.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/2d/3c7ff8b2e0e634c1f58d095f071f52ed3c23ff25be524b0ccae8b71f99f8/coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19", size = 252225, upload-time = "2025-12-08T13:13:55.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/ac/fb03b469d20e9c9a81093575003f959cf91a4a517b783aab090e4538764b/coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be", size = 253559, upload-time = "2025-12-08T13:13:57.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/62/14afa9e792383c66cc0a3b872a06ded6e4ed1079c7d35de274f11d27064e/coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb", size = 249724, upload-time = "2025-12-08T13:13:58.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b7/333f3dab2939070613696ab3ee91738950f0467778c6e5a5052e840646b7/coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8", size = 251582, upload-time = "2025-12-08T13:14:00.642Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/cb/69162bda9381f39b2287265d7e29ee770f7c27c19f470164350a38318764/coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b", size = 249538, upload-time = "2025-12-08T13:14:02.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/76/350387b56a30f4970abe32b90b2a434f87d29f8b7d4ae40d2e8a85aacfb3/coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9", size = 249349, upload-time = "2025-12-08T13:14:04.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/0d/7f6c42b8d59f4c7e43ea3059f573c0dcfed98ba46eb43c68c69e52ae095c/coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927", size = 251011, upload-time = "2025-12-08T13:14:05.505Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/f1/4bb2dff379721bb0b5c649d5c5eaf438462cad824acf32eb1b7ca0c7078e/coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f", size = 221091, upload-time = "2025-12-08T13:14:07.127Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/44/c239da52f373ce379c194b0ee3bcc121020e397242b85f99e0afc8615066/coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc", size = 221904, upload-time = "2025-12-08T13:14:08.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1f/b9f04016d2a29c2e4a0307baefefad1a4ec5724946a2b3e482690486cade/coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b", size = 220480, upload-time = "2025-12-08T13:14:10.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/d4/364a1439766c8e8647860584171c36010ca3226e6e45b1753b1b249c5161/coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28", size = 219074, upload-time = "2025-12-08T13:14:13.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/f4/71ba8be63351e099911051b2089662c03d5671437a0ec2171823c8e03bec/coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe", size = 219342, upload-time = "2025-12-08T13:14:15.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/25/127d8ed03d7711a387d96f132589057213e3aef7475afdaa303412463f22/coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657", size = 260713, upload-time = "2025-12-08T13:14:16.907Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/db/559fbb6def07d25b2243663b46ba9eb5a3c6586c0c6f4e62980a68f0ee1c/coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff", size = 262825, upload-time = "2025-12-08T13:14:18.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/99/6ee5bf7eff884766edb43bd8736b5e1c5144d0fe47498c3779326fe75a35/coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3", size = 265233, upload-time = "2025-12-08T13:14:20.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/90/92f18fe0356ea69e1f98f688ed80cec39f44e9f09a1f26a1bbf017cc67f2/coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b", size = 259779, upload-time = "2025-12-08T13:14:22.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/5d/b312a8b45b37a42ea7d27d7d3ff98ade3a6c892dd48d1d503e773503373f/coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d", size = 262700, upload-time = "2025-12-08T13:14:24.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/f8/b1d0de5c39351eb71c366f872376d09386640840a2e09b0d03973d791e20/coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e", size = 260302, upload-time = "2025-12-08T13:14:26.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/7c/d42f4435bc40c55558b3109a39e2d456cddcec37434f62a1f1230991667a/coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940", size = 259136, upload-time = "2025-12-08T13:14:27.604Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/d3/23413241dc04d47cfe19b9a65b32a2edd67ecd0b817400c2843ebc58c847/coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2", size = 261467, upload-time = "2025-12-08T13:14:29.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/e6/6e063174500eee216b96272c0d1847bf215926786f85c2bd024cf4d02d2f/coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7", size = 221875, upload-time = "2025-12-08T13:14:31.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/46/f4fb293e4cbe3620e3ac2a3e8fd566ed33affb5861a9b20e3dd6c1896cbc/coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc", size = 222982, upload-time = "2025-12-08T13:14:33.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/62/5b3b9018215ed9733fbd1ae3b2ed75c5de62c3b55377a52cae732e1b7805/coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a", size = 221016, upload-time = "2025-12-08T13:14:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/4c/1968f32fb9a2604645827e11ff84a31e59d532e01995f904723b4f5328b3/coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904", size = 210068, upload-time = "2025-12-08T13:14:36.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/0c/0dfe7f0487477d96432e4815537263363fb6dd7289743a796e8e51eabdf2/coverage-7.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:aa124a3683d2af98bd9d9c2bfa7a5076ca7e5ab09fdb96b81fa7d89376ae928f", size = 217535, upload-time = "2025-11-18T13:32:08.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/f5/f9a4a053a5bbff023d3bec259faac8f11a1e5a6479c2ccf586f910d8dac7/coverage-7.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d93fbf446c31c0140208dcd07c5d882029832e8ed7891a39d6d44bd65f2316c3", size = 218044, upload-time = "2025-11-18T13:32:10.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/c5/84fc3697c1fa10cd8571919bf9693f693b7373278daaf3b73e328d502bc8/coverage-7.12.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:52ca620260bd8cd6027317bdd8b8ba929be1d741764ee765b42c4d79a408601e", size = 248440, upload-time = "2025-11-18T13:32:12.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/36/2d93fbf6a04670f3874aed397d5a5371948a076e3249244a9e84fb0e02d6/coverage-7.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f3433ffd541380f3a0e423cff0f4926d55b0cc8c1d160fdc3be24a4c03aa65f7", size = 250361, upload-time = "2025-11-18T13:32:13.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/49/66dc65cc456a6bfc41ea3d0758c4afeaa4068a2b2931bf83be6894cf1058/coverage-7.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7bbb321d4adc9f65e402c677cd1c8e4c2d0105d3ce285b51b4d87f1d5db5245", size = 252472, upload-time = "2025-11-18T13:32:15.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/1f/ebb8a18dffd406db9fcd4b3ae42254aedcaf612470e8712f12041325930f/coverage-7.12.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22a7aade354a72dff3b59c577bfd18d6945c61f97393bc5fb7bd293a4237024b", size = 248592, upload-time = "2025-11-18T13:32:16.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a8/67f213c06e5ea3b3d4980df7dc344d7fea88240b5fe878a5dcbdfe0e2315/coverage-7.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ff651dcd36d2fea66877cd4a82de478004c59b849945446acb5baf9379a1b64", size = 250167, upload-time = "2025-11-18T13:32:17.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/00/e52aef68154164ea40cc8389c120c314c747fe63a04b013a5782e989b77f/coverage-7.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:31b8b2e38391a56e3cea39d22a23faaa7c3fc911751756ef6d2621d2a9daf742", size = 248238, upload-time = "2025-11-18T13:32:19.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/a4/4d88750bcf9d6d66f77865e5a05a20e14db44074c25fd22519777cb69025/coverage-7.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:297bc2da28440f5ae51c845a47c8175a4db0553a53827886e4fb25c66633000c", size = 247964, upload-time = "2025-11-18T13:32:21.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/6b/b74693158899d5b47b0bf6238d2c6722e20ba749f86b74454fac0696bb00/coverage-7.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6ff7651cc01a246908eac162a6a86fc0dbab6de1ad165dfb9a1e2ec660b44984", size = 248862, upload-time = "2025-11-18T13:32:22.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/de/6af6730227ce0e8ade307b1cc4a08e7f51b419a78d02083a86c04ccceb29/coverage-7.12.0-cp311-cp311-win32.whl", hash = "sha256:313672140638b6ddb2c6455ddeda41c6a0b208298034544cfca138978c6baed6", size = 220033, upload-time = "2025-11-18T13:32:23.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/a1/e7f63021a7c4fe20994359fcdeae43cbef4a4d0ca36a5a1639feeea5d9e1/coverage-7.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1783ed5bd0d5938d4435014626568dc7f93e3cb99bc59188cc18857c47aa3c4", size = 220966, upload-time = "2025-11-18T13:32:25.599Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/e8/deae26453f37c20c3aa0c4433a1e32cdc169bf415cce223a693117aa3ddd/coverage-7.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:4648158fd8dd9381b5847622df1c90ff314efbfc1df4550092ab6013c238a5fc", size = 219637, upload-time = "2025-11-18T13:32:27.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/bf/638c0427c0f0d47638242e2438127f3c8ee3cfc06c7fdeb16778ed47f836/coverage-7.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:29644c928772c78512b48e14156b81255000dcfd4817574ff69def189bcb3647", size = 217704, upload-time = "2025-11-18T13:32:28.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/e1/706fae6692a66c2d6b871a608bbde0da6281903fa0e9f53a39ed441da36a/coverage-7.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8638cbb002eaa5d7c8d04da667813ce1067080b9a91099801a0053086e52b736", size = 218064, upload-time = "2025-11-18T13:32:30.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/8b/eb0231d0540f8af3ffda39720ff43cb91926489d01524e68f60e961366e4/coverage-7.12.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:083631eeff5eb9992c923e14b810a179798bb598e6a0dd60586819fc23be6e60", size = 249560, upload-time = "2025-11-18T13:32:31.835Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/a1/67fb52af642e974d159b5b379e4d4c59d0ebe1288677fbd04bbffe665a82/coverage-7.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:99d5415c73ca12d558e07776bd957c4222c687b9f1d26fa0e1b57e3598bdcde8", size = 252318, upload-time = "2025-11-18T13:32:33.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/e5/38228f31b2c7665ebf9bdfdddd7a184d56450755c7e43ac721c11a4b8dab/coverage-7.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e949ebf60c717c3df63adb4a1a366c096c8d7fd8472608cd09359e1bd48ef59f", size = 253403, upload-time = "2025-11-18T13:32:34.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/4b/df78e4c8188f9960684267c5a4897836f3f0f20a20c51606ee778a1d9749/coverage-7.12.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d907ddccbca819afa2cd014bc69983b146cca2735a0b1e6259b2a6c10be1e70", size = 249984, upload-time = "2025-11-18T13:32:35.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/51/bb163933d195a345c6f63eab9e55743413d064c291b6220df754075c2769/coverage-7.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1518ecbad4e6173f4c6e6c4a46e49555ea5679bf3feda5edb1b935c7c44e8a0", size = 251339, upload-time = "2025-11-18T13:32:37.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/40/c9b29cdb8412c837cdcbc2cfa054547dd83affe6cbbd4ce4fdb92b6ba7d1/coverage-7.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51777647a749abdf6f6fd8c7cffab12de68ab93aab15efc72fbbb83036c2a068", size = 249489, upload-time = "2025-11-18T13:32:39.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/da/b3131e20ba07a0de4437a50ef3b47840dfabf9293675b0cd5c2c7f66dd61/coverage-7.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:42435d46d6461a3b305cdfcad7cdd3248787771f53fe18305548cba474e6523b", size = 249070, upload-time = "2025-11-18T13:32:40.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/81/b653329b5f6302c08d683ceff6785bc60a34be9ae92a5c7b63ee7ee7acec/coverage-7.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5bcead88c8423e1855e64b8057d0544e33e4080b95b240c2a355334bb7ced937", size = 250929, upload-time = "2025-11-18T13:32:42.915Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/00/250ac3bca9f252a5fb1338b5ad01331ebb7b40223f72bef5b1b2cb03aa64/coverage-7.12.0-cp312-cp312-win32.whl", hash = "sha256:dcbb630ab034e86d2a0f79aefd2be07e583202f41e037602d438c80044957baa", size = 220241, upload-time = "2025-11-18T13:32:44.665Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/1c/77e79e76d37ce83302f6c21980b45e09f8aa4551965213a10e62d71ce0ab/coverage-7.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:2fd8354ed5d69775ac42986a691fbf68b4084278710cee9d7c3eaa0c28fa982a", size = 221051, upload-time = "2025-11-18T13:32:46.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/f5/641b8a25baae564f9e52cac0e2667b123de961985709a004e287ee7663cc/coverage-7.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:737c3814903be30695b2de20d22bcc5428fdae305c61ba44cdc8b3252984c49c", size = 219692, upload-time = "2025-11-18T13:32:47.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/14/771700b4048774e48d2c54ed0c674273702713c9ee7acdfede40c2666747/coverage-7.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47324fffca8d8eae7e185b5bb20c14645f23350f870c1649003618ea91a78941", size = 217725, upload-time = "2025-11-18T13:32:49.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/a7/3aa4144d3bcb719bf67b22d2d51c2d577bf801498c13cb08f64173e80497/coverage-7.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ccf3b2ede91decd2fb53ec73c1f949c3e034129d1e0b07798ff1d02ea0c8fa4a", size = 218098, upload-time = "2025-11-18T13:32:50.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/9c/b846bbc774ff81091a12a10203e70562c91ae71badda00c5ae5b613527b1/coverage-7.12.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b365adc70a6936c6b0582dc38746b33b2454148c02349345412c6e743efb646d", size = 249093, upload-time = "2025-11-18T13:32:52.554Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/b6/67d7c0e1f400b32c883e9342de4a8c2ae7c1a0b57c5de87622b7262e2309/coverage-7.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc13baf85cd8a4cfcf4a35c7bc9d795837ad809775f782f697bf630b7e200211", size = 251686, upload-time = "2025-11-18T13:32:54.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/75/b095bd4b39d49c3be4bffbb3135fea18a99a431c52dd7513637c0762fecb/coverage-7.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:099d11698385d572ceafb3288a5b80fe1fc58bf665b3f9d362389de488361d3d", size = 252930, upload-time = "2025-11-18T13:32:56.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/f3/466f63015c7c80550bead3093aacabf5380c1220a2a93c35d374cae8f762/coverage-7.12.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:473dc45d69694069adb7680c405fb1e81f60b2aff42c81e2f2c3feaf544d878c", size = 249296, upload-time = "2025-11-18T13:32:58.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/86/eba2209bf2b7e28c68698fc13437519a295b2d228ba9e0ec91673e09fa92/coverage-7.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:583f9adbefd278e9de33c33d6846aa8f5d164fa49b47144180a0e037f0688bb9", size = 251068, upload-time = "2025-11-18T13:32:59.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/55/ca8ae7dbba962a3351f18940b359b94c6bafdd7757945fdc79ec9e452dc7/coverage-7.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b2089cc445f2dc0af6f801f0d1355c025b76c24481935303cf1af28f636688f0", size = 249034, upload-time = "2025-11-18T13:33:01.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/d7/39136149325cad92d420b023b5fd900dabdd1c3a0d1d5f148ef4a8cedef5/coverage-7.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:950411f1eb5d579999c5f66c62a40961f126fc71e5e14419f004471957b51508", size = 248853, upload-time = "2025-11-18T13:33:02.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/b6/76e1add8b87ef60e00643b0b7f8f7bb73d4bf5249a3be19ebefc5793dd25/coverage-7.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b1aab7302a87bafebfe76b12af681b56ff446dc6f32ed178ff9c092ca776e6bc", size = 250619, upload-time = "2025-11-18T13:33:04.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/87/924c6dc64f9203f7a3c1832a6a0eee5a8335dbe5f1bdadcc278d6f1b4d74/coverage-7.12.0-cp313-cp313-win32.whl", hash = "sha256:d7e0d0303c13b54db495eb636bc2465b2fb8475d4c8bcec8fe4b5ca454dfbae8", size = 220261, upload-time = "2025-11-18T13:33:06.493Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/77/dd4aff9af16ff776bf355a24d87eeb48fc6acde54c907cc1ea89b14a8804/coverage-7.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce61969812d6a98a981d147d9ac583a36ac7db7766f2e64a9d4d059c2fe29d07", size = 221072, upload-time = "2025-11-18T13:33:07.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/49/5c9dc46205fef31b1b226a6e16513193715290584317fd4df91cdaf28b22/coverage-7.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bcec6f47e4cb8a4c2dc91ce507f6eefc6a1b10f58df32cdc61dff65455031dfc", size = 219702, upload-time = "2025-11-18T13:33:09.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/62/f87922641c7198667994dd472a91e1d9b829c95d6c29529ceb52132436ad/coverage-7.12.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:459443346509476170d553035e4a3eed7b860f4fe5242f02de1010501956ce87", size = 218420, upload-time = "2025-11-18T13:33:11.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/dd/1cc13b2395ef15dbb27d7370a2509b4aee77890a464fb35d72d428f84871/coverage-7.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:04a79245ab2b7a61688958f7a855275997134bc84f4a03bc240cf64ff132abf6", size = 218773, upload-time = "2025-11-18T13:33:12.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/40/35773cc4bb1e9d4658d4fb669eb4195b3151bef3bbd6f866aba5cd5dac82/coverage-7.12.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09a86acaaa8455f13d6a99221d9654df249b33937b4e212b4e5a822065f12aa7", size = 260078, upload-time = "2025-11-18T13:33:14.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ee/231bb1a6ffc2905e396557585ebc6bdc559e7c66708376d245a1f1d330fc/coverage-7.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:907e0df1b71ba77463687a74149c6122c3f6aac56c2510a5d906b2f368208560", size = 262144, upload-time = "2025-11-18T13:33:15.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/be/32f4aa9f3bf0b56f3971001b56508352c7753915345d45fab4296a986f01/coverage-7.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b57e2d0ddd5f0582bae5437c04ee71c46cd908e7bc5d4d0391f9a41e812dd12", size = 264574, upload-time = "2025-11-18T13:33:17.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7c/00489fcbc2245d13ab12189b977e0cf06ff3351cb98bc6beba8bd68c5902/coverage-7.12.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58c1c6aa677f3a1411fe6fb28ec3a942e4f665df036a3608816e0847fad23296", size = 259298, upload-time = "2025-11-18T13:33:18.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/b4/f0760d65d56c3bea95b449e02570d4abd2549dc784bf39a2d4721a2d8ceb/coverage-7.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4c589361263ab2953e3c4cd2a94db94c4ad4a8e572776ecfbad2389c626e4507", size = 262150, upload-time = "2025-11-18T13:33:20.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/71/9a9314df00f9326d78c1e5a910f520d599205907432d90d1c1b7a97aa4b1/coverage-7.12.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:91b810a163ccad2e43b1faa11d70d3cf4b6f3d83f9fd5f2df82a32d47b648e0d", size = 259763, upload-time = "2025-11-18T13:33:22.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/34/01a0aceed13fbdf925876b9a15d50862eb8845454301fe3cdd1df08b2182/coverage-7.12.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:40c867af715f22592e0d0fb533a33a71ec9e0f73a6945f722a0c85c8c1cbe3a2", size = 258653, upload-time = "2025-11-18T13:33:24.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/04/81d8fd64928acf1574bbb0181f66901c6c1c6279c8ccf5f84259d2c68ae9/coverage-7.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:68b0d0a2d84f333de875666259dadf28cc67858bc8fd8b3f1eae84d3c2bec455", size = 260856, upload-time = "2025-11-18T13:33:26.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/76/fa2a37bfaeaf1f766a2d2360a25a5297d4fb567098112f6517475eee120b/coverage-7.12.0-cp313-cp313t-win32.whl", hash = "sha256:73f9e7fbd51a221818fd11b7090eaa835a353ddd59c236c57b2199486b116c6d", size = 220936, upload-time = "2025-11-18T13:33:28.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/52/60f64d932d555102611c366afb0eb434b34266b1d9266fc2fe18ab641c47/coverage-7.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:24cff9d1f5743f67db7ba46ff284018a6e9aeb649b67aa1e70c396aa1b7cb23c", size = 222001, upload-time = "2025-11-18T13:33:29.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/df/c303164154a5a3aea7472bf323b7c857fed93b26618ed9fc5c2955566bb0/coverage-7.12.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c87395744f5c77c866d0f5a43d97cc39e17c7f1cb0115e54a2fe67ca75c5d14d", size = 220273, upload-time = "2025-11-18T13:33:31.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/2e/fc12db0883478d6e12bbd62d481210f0c8daf036102aa11434a0c5755825/coverage-7.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a1c59b7dc169809a88b21a936eccf71c3895a78f5592051b1af8f4d59c2b4f92", size = 217777, upload-time = "2025-11-18T13:33:32.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/c1/ce3e525d223350c6ec16b9be8a057623f54226ef7f4c2fee361ebb6a02b8/coverage-7.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8787b0f982e020adb732b9f051f3e49dd5054cebbc3f3432061278512a2b1360", size = 218100, upload-time = "2025-11-18T13:33:34.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/87/113757441504aee3808cb422990ed7c8bcc2d53a6779c66c5adef0942939/coverage-7.12.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ea5a9f7dc8877455b13dd1effd3202e0bca72f6f3ab09f9036b1bcf728f69ac", size = 249151, upload-time = "2025-11-18T13:33:36.135Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/1d/9529d9bd44049b6b05bb319c03a3a7e4b0a8a802d28fa348ad407e10706d/coverage-7.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fdba9f15849534594f60b47c9a30bc70409b54947319a7c4fd0e8e3d8d2f355d", size = 251667, upload-time = "2025-11-18T13:33:37.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/bb/567e751c41e9c03dc29d3ce74b8c89a1e3396313e34f255a2a2e8b9ebb56/coverage-7.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a00594770eb715854fb1c57e0dea08cce6720cfbc531accdb9850d7c7770396c", size = 253003, upload-time = "2025-11-18T13:33:39.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/b3/c2cce2d8526a02fb9e9ca14a263ca6fc074449b33a6afa4892838c903528/coverage-7.12.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5560c7e0d82b42eb1951e4f68f071f8017c824ebfd5a6ebe42c60ac16c6c2434", size = 249185, upload-time = "2025-11-18T13:33:42.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/a7/967f93bb66e82c9113c66a8d0b65ecf72fc865adfba5a145f50c7af7e58d/coverage-7.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2e26b481c9159c2773a37947a9718cfdc58893029cdfb177531793e375cfc", size = 251025, upload-time = "2025-11-18T13:33:43.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b2/f2f6f56337bc1af465d5b2dc1ee7ee2141b8b9272f3bf6213fcbc309a836/coverage-7.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6e1a8c066dabcde56d5d9fed6a66bc19a2883a3fe051f0c397a41fc42aedd4cc", size = 248979, upload-time = "2025-11-18T13:33:46.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/7a/bf4209f45a4aec09d10a01a57313a46c0e0e8f4c55ff2965467d41a92036/coverage-7.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f7ba9da4726e446d8dd8aae5a6cd872511184a5d861de80a86ef970b5dacce3e", size = 248800, upload-time = "2025-11-18T13:33:47.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/b7/1e01b8696fb0521810f60c5bbebf699100d6754183e6cc0679bf2ed76531/coverage-7.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e0f483ab4f749039894abaf80c2f9e7ed77bbf3c737517fb88c8e8e305896a17", size = 250460, upload-time = "2025-11-18T13:33:49.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/ae/84324fb9cb46c024760e706353d9b771a81b398d117d8c1fe010391c186f/coverage-7.12.0-cp314-cp314-win32.whl", hash = "sha256:76336c19a9ef4a94b2f8dc79f8ac2da3f193f625bb5d6f51a328cd19bfc19933", size = 220533, upload-time = "2025-11-18T13:33:51.16Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/71/1033629deb8460a8f97f83e6ac4ca3b93952e2b6f826056684df8275e015/coverage-7.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7c1059b600aec6ef090721f8f633f60ed70afaffe8ecab85b59df748f24b31fe", size = 221348, upload-time = "2025-11-18T13:33:52.776Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/5f/ac8107a902f623b0c251abdb749be282dc2ab61854a8a4fcf49e276fce2f/coverage-7.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:172cf3a34bfef42611963e2b661302a8931f44df31629e5b1050567d6b90287d", size = 219922, upload-time = "2025-11-18T13:33:54.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/6e/f27af2d4da367f16077d21ef6fe796c874408219fa6dd3f3efe7751bd910/coverage-7.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:aa7d48520a32cb21c7a9b31f81799e8eaec7239db36c3b670be0fa2403828d1d", size = 218511, upload-time = "2025-11-18T13:33:56.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/dd/65fd874aa460c30da78f9d259400d8e6a4ef457d61ab052fd248f0050558/coverage-7.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:90d58ac63bc85e0fb919f14d09d6caa63f35a5512a2205284b7816cafd21bb03", size = 218771, upload-time = "2025-11-18T13:33:57.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/e0/7c6b71d327d8068cb79c05f8f45bf1b6145f7a0de23bbebe63578fe5240a/coverage-7.12.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ca8ecfa283764fdda3eae1bdb6afe58bf78c2c3ec2b2edcb05a671f0bba7b3f9", size = 260151, upload-time = "2025-11-18T13:33:59.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/ce/4697457d58285b7200de6b46d606ea71066c6e674571a946a6ea908fb588/coverage-7.12.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:874fe69a0785d96bd066059cd4368022cebbec1a8958f224f0016979183916e6", size = 262257, upload-time = "2025-11-18T13:34:01.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/33/acbc6e447aee4ceba88c15528dbe04a35fb4d67b59d393d2e0d6f1e242c1/coverage-7.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5b3c889c0b8b283a24d721a9eabc8ccafcfc3aebf167e4cd0d0e23bf8ec4e339", size = 264671, upload-time = "2025-11-18T13:34:02.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/ec/e2822a795c1ed44d569980097be839c5e734d4c0c1119ef8e0a073496a30/coverage-7.12.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8bb5b894b3ec09dcd6d3743229dc7f2c42ef7787dc40596ae04c0edda487371e", size = 259231, upload-time = "2025-11-18T13:34:04.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c5/a7ec5395bb4a49c9b7ad97e63f0c92f6bf4a9e006b1393555a02dae75f16/coverage-7.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:79a44421cd5fba96aa57b5e3b5a4d3274c449d4c622e8f76882d76635501fd13", size = 262137, upload-time = "2025-11-18T13:34:06.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/0c/02c08858b764129f4ecb8e316684272972e60777ae986f3865b10940bdd6/coverage-7.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:33baadc0efd5c7294f436a632566ccc1f72c867f82833eb59820ee37dc811c6f", size = 259745, upload-time = "2025-11-18T13:34:08.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/04/4fd32b7084505f3829a8fe45c1a74a7a728cb251aaadbe3bec04abcef06d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c406a71f544800ef7e9e0000af706b88465f3573ae8b8de37e5f96c59f689ad1", size = 258570, upload-time = "2025-11-18T13:34:09.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/35/2365e37c90df4f5342c4fa202223744119fe31264ee2924f09f074ea9b6d/coverage-7.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e71bba6a40883b00c6d571599b4627f50c360b3d0d02bfc658168936be74027b", size = 260899, upload-time = "2025-11-18T13:34:11.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/56/26ab0464ca733fa325e8e71455c58c1c374ce30f7c04cebb88eabb037b18/coverage-7.12.0-cp314-cp314t-win32.whl", hash = "sha256:9157a5e233c40ce6613dead4c131a006adfda70e557b6856b97aceed01b0e27a", size = 221313, upload-time = "2025-11-18T13:34:12.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/1c/017a3e1113ed34d998b27d2c6dba08a9e7cb97d362f0ec988fcd873dcf81/coverage-7.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e84da3a0fd233aeec797b981c51af1cabac74f9bd67be42458365b30d11b5291", size = 222423, upload-time = "2025-11-18T13:34:15.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/36/bcc504fdd5169301b52568802bb1b9cdde2e27a01d39fbb3b4b508ab7c2c/coverage-7.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:01d24af36fedda51c2b1aca56e4330a3710f83b02a5ff3743a6b015ffa7c9384", size = 220459, upload-time = "2025-11-18T13:34:17.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a3/43b749004e3c09452e39bb56347a008f0a0668aad37324a99b5c8ca91d9e/coverage-7.12.0-py3-none-any.whl", hash = "sha256:159d50c0b12e060b15ed3d39f87ed43d4f7f7ad40b8a534f4dd331adbb51104a", size = 209503, upload-time = "2025-11-18T13:34:18.892Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -247,7 +247,6 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dibbler"
|
||||
version = "1.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "brother-ql" },
|
||||
@@ -261,7 +260,6 @@ dependencies = [
|
||||
test = [
|
||||
{ name = "coverage-badge" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-benchmark", extra = ["histogram"] },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-html" },
|
||||
{ name = "sqlparse" },
|
||||
@@ -280,7 +278,6 @@ requires-dist = [
|
||||
test = [
|
||||
{ name = "coverage-badge", specifier = ">=1.1.2" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-benchmark", extras = ["histogram"], specifier = ">=5.2.3" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-html", specifier = ">=4.1.1" },
|
||||
{ name = "sqlparse", specifier = ">=0.5.4" },
|
||||
@@ -391,18 +388,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "importlib-metadata"
|
||||
version = "8.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "zipp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.0"
|
||||
@@ -896,36 +881,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "py-cpuinfo"
|
||||
version = "9.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/a8/d832f7293ebb21690860d2e01d8115e5ff6f2ae8bbdc953f0eb0fa4bd2c7/py-cpuinfo-9.0.0.tar.gz", hash = "sha256:3cdbbf3fac90dc6f118bfd64384f309edeadd902d7c8fb17f02ffa1fc3f49690", size = 104716, upload-time = "2022-10-25T20:38:06.303Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/a9/023730ba63db1e494a271cb018dcd361bd2c917ba7004c3e49d5daf795a2/py_cpuinfo-9.0.0-py3-none-any.whl", hash = "sha256:859625bc251f64e21f077d099d4162689c762b5d6a4c3c97553d56241c9674d5", size = 22335, upload-time = "2022-10-25T20:38:27.636Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygal"
|
||||
version = "3.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b0/b6/04176faeb312c84d7f9bc1a810f96ee38d15597e226bb9bda59f3a5cb122/pygal-3.1.0.tar.gz", hash = "sha256:fbdee7351a7423e7907fb8a9c3b77305f6b5678cb2e6fd0db36a8825e42955ec", size = 81006, upload-time = "2025-12-09T10:29:19.587Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/4c/2862dd25352fe4b22ec7760d4fa12cb692587cd7ec3e378cbf644fc0d2a8/pygal-3.1.0-py3-none-any.whl", hash = "sha256:4e923490f3490c90c481f4535fa3adcda20ff374257ab9d8ae897f91b632c0bb", size = 130171, upload-time = "2025-12-09T10:29:16.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygaljs"
|
||||
version = "1.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/75/19/3a53f34232a9e6ddad665e71c83693c5db9a31f71785105905c5bc9fbbba/pygaljs-1.0.2.tar.gz", hash = "sha256:0b71ee32495dcba5fbb4a0476ddbba07658ad65f5675e4ad409baf154dec5111", size = 89711, upload-time = "2020-04-03T07:51:44.082Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/6f/07dab31ca496feda35cf3455b9e9380c43b5c685bb54ad890831c790da38/pygaljs-1.0.2-py2.py3-none-any.whl", hash = "sha256:d75e18cb21cc2cda40c45c3ee690771e5e3d4652bf57206f20137cf475c0dbe8", size = 91111, upload-time = "2020-04-03T07:51:42.658Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.19.2"
|
||||
@@ -960,26 +915,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-benchmark"
|
||||
version = "5.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "py-cpuinfo" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/34/9f732b76456d64faffbef6232f1f9dbec7a7c4999ff46282fa418bd1af66/pytest_benchmark-5.2.3.tar.gz", hash = "sha256:deb7317998a23c650fd4ff76e1230066a76cb45dcece0aca5607143c619e7779", size = 341340, upload-time = "2025-11-09T18:48:43.215Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/29/e756e715a48959f1c0045342088d7ca9762a2f509b945f362a316e9412b7/pytest_benchmark-5.2.3-py3-none-any.whl", hash = "sha256:bc839726ad20e99aaa0d11a127445457b4219bdb9e80a1afc4b51da7f96b0803", size = 45255, upload-time = "2025-11-09T18:48:39.765Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
histogram = [
|
||||
{ name = "pygal" },
|
||||
{ name = "pygaljs" },
|
||||
{ name = "setuptools" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.0.0"
|
||||
@@ -1070,44 +1005,39 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.45"
|
||||
version = "2.0.44"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/1c/769552a9d840065137272ebe86ffbb0bc92b0f1e0a68ee5266a225f8cd7b/sqlalchemy-2.0.45-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2e90a344c644a4fa871eb01809c32096487928bd2038bf10f3e4515cb688cc56", size = 2153860, upload-time = "2025-12-10T20:03:23.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/f8/9be54ff620e5b796ca7b44670ef58bc678095d51b0e89d6e3102ea468216/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8c8b41b97fba5f62349aa285654230296829672fc9939cd7f35aab246d1c08b", size = 3309379, upload-time = "2025-12-09T22:06:07.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/2b/60ce3ee7a5ae172bfcd419ce23259bb874d2cddd44f67c5df3760a1e22f9/sqlalchemy-2.0.45-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:12c694ed6468333a090d2f60950e4250b928f457e4962389553d6ba5fe9951ac", size = 3309948, upload-time = "2025-12-09T22:09:57.643Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/42/bac8d393f5db550e4e466d03d16daaafd2bad1f74e48c12673fb499a7fc1/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f7d27a1d977a1cfef38a0e2e1ca86f09c4212666ce34e6ae542f3ed0a33bc606", size = 3261239, upload-time = "2025-12-09T22:06:08.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/12/43dc70a0528c59842b04ea1c1ed176f072a9b383190eb015384dd102fb19/sqlalchemy-2.0.45-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d62e47f5d8a50099b17e2bfc1b0c7d7ecd8ba6b46b1507b58cc4f05eefc3bb1c", size = 3284065, upload-time = "2025-12-09T22:09:59.454Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/9c/563049cf761d9a2ec7bc489f7879e9d94e7b590496bea5bbee9ed7b4cc32/sqlalchemy-2.0.45-cp311-cp311-win32.whl", hash = "sha256:3c5f76216e7b85770d5bb5130ddd11ee89f4d52b11783674a662c7dd57018177", size = 2113480, upload-time = "2025-12-09T21:29:57.03Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/fa/09d0a11fe9f15c7fa5c7f0dd26be3d235b0c0cbf2f9544f43bc42efc8a24/sqlalchemy-2.0.45-cp311-cp311-win_amd64.whl", hash = "sha256:a15b98adb7f277316f2c276c090259129ee4afca783495e212048daf846654b2", size = 2138407, upload-time = "2025-12-09T21:29:58.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/81/15d7c161c9ddf0900b076b55345872ed04ff1ed6a0666e5e94ab44b0163c/sqlalchemy-2.0.44-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0fe3917059c7ab2ee3f35e77757062b1bea10a0b6ca633c58391e3f3c6c488dd", size = 2140517, upload-time = "2025-10-10T15:36:15.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/d5/4abd13b245c7d91bdf131d4916fd9e96a584dac74215f8b5bc945206a974/sqlalchemy-2.0.44-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:de4387a354ff230bc979b46b2207af841dc8bf29847b6c7dbe60af186d97aefa", size = 2130738, upload-time = "2025-10-10T15:36:16.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/3c/8418969879c26522019c1025171cefbb2a8586b6789ea13254ac602986c0/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3678a0fb72c8a6a29422b2732fe423db3ce119c34421b5f9955873eb9b62c1e", size = 3304145, upload-time = "2025-10-10T15:34:19.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/2d/fdb9246d9d32518bda5d90f4b65030b9bf403a935cfe4c36a474846517cb/sqlalchemy-2.0.44-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cf6872a23601672d61a68f390e44703442639a12ee9dd5a88bbce52a695e46e", size = 3304511, upload-time = "2025-10-10T15:47:05.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/fb/40f2ad1da97d5c83f6c1269664678293d3fe28e90ad17a1093b735420549/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:329aa42d1be9929603f406186630135be1e7a42569540577ba2c69952b7cf399", size = 3235161, upload-time = "2025-10-10T15:34:21.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/cb/7cf4078b46752dca917d18cf31910d4eff6076e5b513c2d66100c4293d83/sqlalchemy-2.0.44-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:70e03833faca7166e6a9927fbee7c27e6ecde436774cd0b24bbcc96353bce06b", size = 3261426, upload-time = "2025-10-10T15:47:07.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/3b/55c09b285cb2d55bdfa711e778bdffdd0dc3ffa052b0af41f1c5d6e582fa/sqlalchemy-2.0.44-cp311-cp311-win32.whl", hash = "sha256:253e2f29843fb303eca6b2fc645aca91fa7aa0aa70b38b6950da92d44ff267f3", size = 2105392, upload-time = "2025-10-10T15:38:20.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/23/907193c2f4d680aedbfbdf7bf24c13925e3c7c292e813326c1b84a0b878e/sqlalchemy-2.0.44-cp311-cp311-win_amd64.whl", hash = "sha256:7a8694107eb4308a13b425ca8c0e67112f8134c846b6e1f722698708741215d5", size = 2130293, upload-time = "2025-10-10T15:38:21.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c4/59c7c9b068e6813c898b771204aad36683c96318ed12d4233e1b18762164/sqlalchemy-2.0.44-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72fea91746b5890f9e5e0997f16cbf3d53550580d76355ba2d998311b17b2250", size = 2139675, upload-time = "2025-10-10T16:03:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ae/eeb0920537a6f9c5a3708e4a5fc55af25900216bdb4847ec29cfddf3bf3a/sqlalchemy-2.0.44-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:585c0c852a891450edbb1eaca8648408a3cc125f18cf433941fa6babcc359e29", size = 2127726, upload-time = "2025-10-10T16:03:35.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/d5/2ebbabe0379418eda8041c06b0b551f213576bfe4c2f09d77c06c07c8cc5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b94843a102efa9ac68a7a30cd46df3ff1ed9c658100d30a725d10d9c60a2f44", size = 3327603, upload-time = "2025-10-10T15:35:28.322Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e5/5aa65852dadc24b7d8ae75b7efb8d19303ed6ac93482e60c44a585930ea5/sqlalchemy-2.0.44-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:119dc41e7a7defcefc57189cfa0e61b1bf9c228211aba432b53fb71ef367fda1", size = 3337842, upload-time = "2025-10-10T15:43:45.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/92/648f1afd3f20b71e880ca797a960f638d39d243e233a7082c93093c22378/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0765e318ee9179b3718c4fd7ba35c434f4dd20332fbc6857a5e8df17719c24d7", size = 3264558, upload-time = "2025-10-10T15:35:29.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/cf/e27d7ee61a10f74b17740918e23cbc5bc62011b48282170dc4c66da8ec0f/sqlalchemy-2.0.44-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2e7b5b079055e02d06a4308d0481658e4f06bc7ef211567edc8f7d5dce52018d", size = 3301570, upload-time = "2025-10-10T15:43:48.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/3d/3116a9a7b63e780fb402799b6da227435be878b6846b192f076d2f838654/sqlalchemy-2.0.44-cp312-cp312-win32.whl", hash = "sha256:846541e58b9a81cce7dee8329f352c318de25aa2f2bbe1e31587eb1f057448b4", size = 2103447, upload-time = "2025-10-10T15:03:21.678Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/83/24690e9dfc241e6ab062df82cc0df7f4231c79ba98b273fa496fb3dd78ed/sqlalchemy-2.0.44-cp312-cp312-win_amd64.whl", hash = "sha256:7cbcb47fd66ab294703e1644f78971f6f2f1126424d2b300678f419aa73c7b6e", size = 2130912, upload-time = "2025-10-10T15:03:24.656Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1176,12 +1106,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zipp"
|
||||
version = "3.23.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user