68 Commits

Author SHA1 Message Date
25c604cbca worbling... 2026-02-15 12:42:06 +01:00
6e0b207284 thing 2026-02-15 12:17:18 +01:00
fa51ede141 other test thing 2026-02-15 12:16:50 +01:00
e6d276e3ac test other thing 2026-02-15 12:05:54 +01:00
8ad532878e don't use not builtin 2026-02-15 11:31:05 +01:00
978c5fbb51 testing 2026-02-15 00:08:59 +01:00
55457fbeac init 2026-02-14 23:28:47 +01:00
a4c4079324 use convention for yes/no questions 2026-02-14 19:36:51 +01:00
fb0f24cb67 fix database verification for views 2026-02-05 02:50:11 +09:00
3d555ca9d1 menus/faq: fix indentation 2026-02-05 01:52:56 +09:00
af5710d663 verify database connection before starting 2026-02-05 01:39:40 +09:00
4d88409e97 helpers: fix search_user 2026-02-05 01:39:12 +09:00
72cd066414 example-config.toml: fix sqlite default path 2026-02-05 01:38:35 +09:00
b1bb1e556b Add --version flag to cli 2026-02-05 00:41:06 +09:00
70b04c0c45 Fix a bunch more lints 2026-02-04 22:59:18 +09:00
7bea5b0b96 Remove need for clear 2026-02-04 22:16:45 +09:00
3123b8b474 loop: disable autocommits, reset db session on looping 2026-02-04 00:38:40 +09:00
9091adedad stats: fix balance stat when missing database rows 2026-02-04 00:34:45 +09:00
94955cb706 treewide: fix a bunch more typing issues 2026-02-04 00:28:29 +09:00
3b6cd1d354 buymenu: fix warning message escapes 2026-02-04 00:24:36 +09:00
c2ee66c394 treewide: format 2026-02-04 00:01:38 +09:00
b5b2706085 helpermenus: add some more types 2026-02-04 00:01:17 +09:00
bf9cea7dfc loop: disable autoflushing, don't expire session on commit 2026-02-03 23:32:19 +09:00
cf945143ba treewide: fix a bunch of lints 2026-02-03 23:24:37 +09:00
e84b43e2a0 pyproject.toml: add ruff linting rules 2026-02-03 23:24:19 +09:00
17fc23ba97 menus/Menu: never unset sql_session 2026-02-03 23:02:11 +09:00
45179a9c43 loop: don't overload main name 2026-02-03 23:01:36 +09:00
dfaa818f46 treewide: rollback if commit was unsuccessful 2026-02-03 22:52:43 +09:00
ec43f67e58 flake.nix: fix nix run 2026-01-27 19:42:21 +09:00
1b09a904cb menus/mainmenu: register sql session in menu 2026-01-27 19:40:17 +09:00
8e84669d9b Temporarily disable brother-ql + friends, update to python 3.13 2026-01-26 13:02:34 +09:00
1d01e1b2cb package.nix: add clear to $PATH 2026-01-26 02:30:10 +09:00
019f419b12 models: a bit of back population 2026-01-25 22:54:01 +09:00
3bab62b3ac treewide: types, types and more types 2026-01-25 22:53:45 +09:00
e771fb0240 Propagate sql_session through constructors 2026-01-25 18:38:22 +09:00
2331e53795 config: structured database config 2026-01-25 18:08:50 +09:00
2ae651a1fa README: add link to wiki docs 2026-01-25 18:08:49 +09:00
76f07841be module.nix: fix lib.getExe warnings 2026-01-25 18:08:49 +09:00
ecaec99212 Replace configparser with tomllib 2026-01-25 18:08:49 +09:00
cb385097dc README: add note about vm 2026-01-11 22:36:51 +09:00
b86962ef0e flake.nix: system -> stdenv.hostPlatform.system 2026-01-09 06:14:35 +09:00
9c0bd54be6 parse config file argument as Path 2026-01-09 05:45:43 +09:00
919d7a5afe assert database_url is present 2026-01-09 05:45:42 +09:00
ddca959ad6 pyproject.toml: psycopg2 -> psycopg2-binary 2026-01-09 05:45:40 +09:00
1733843b77 pyproject.toml: set authors 2026-01-06 17:33:15 +09:00
4ed68ff05c nix: yeet skrott, massive module modifications tm, wrap package and more
Sorry for the kinda big commit that does everything at once

This change does the following:
- yeets skrott and skrot-specific settings from the NixOS module,
- adds a bunch more settings and generalizations to the NixOS module,
- adds two VM NixOS configurations for interactive testing
- wraps the nix package so that `less` is always present in `$PATH`
- yeah, that's about it

kthxbye
2026-01-06 17:01:21 +09:00
78161a96be Try to read config from /etc/dibbler/dibbler.conf 2026-01-06 16:01:34 +09:00
f4b5e1d6d4 pyproject.toml: set package version, fix nix package 2026-01-06 14:09:38 +09:00
634716956e uv.lock: init 2025-12-08 18:26:08 +09:00
fb81eef26f flake.lock: bump 2025-12-08 18:25:59 +09:00
e9d30b63a5 flake.lock: bump 2025-06-07 15:13:32 +02:00
0844843e59 remove all the image related things from dibbler service 2025-05-17 20:05:35 +02:00
70677f7f79 db: handle database.url_file 2025-05-17 19:19:10 +02:00
4a4f0e6947 module.nix: config -> settings 2025-05-05 14:52:28 +02:00
a4d10ad0c7 Merge pull request 'Seed test data' (#16) from seed_test into master
Reviewed-on: #16
Reviewed-by: Oystein Kristoffer Tveit <oysteikt@pvv.ntnu.no>
2025-03-30 21:45:37 +02:00
a654baba11 ruff format 2025-03-30 21:44:37 +02:00
e69d04dcd0 mock script, og mock data. 2025-03-29 22:48:30 +01:00
b2a6384f31 la tilbake uv, en project manager 2025-03-29 22:46:30 +01:00
4f89765070 ignorer bifiler fra hatchling 2025-03-29 22:42:36 +01:00
914e5b4e50 fjerner __pyachce__, fra repo tracking 2025-03-29 22:37:11 +01:00
de20bad7dd remove conf.py 2025-03-19 18:47:23 +01:00
4bab5e7e21 treewide: fix brother-ql usage 2025-03-19 18:47:16 +01:00
b85a6535fe shell.nix: add python with all packages 2025-03-19 18:14:42 +01:00
22a09b4177 README: add more information 2025-03-19 18:06:40 +01:00
c39b15d1a8 .envrc: init 2025-03-19 17:50:48 +01:00
122ac2ab18 treewide: update everything nix 2025-03-19 17:50:14 +01:00
28228beccd pyproject.toml: remove invalid license
This license field was added without any of the earlier contributors
consent on accident. It is not valid
2025-03-17 21:03:55 +01:00
8a6a0c12ba Merge pull request #3 from Programvareverkstedet/restructure-project
Restructure project
2023-09-02 21:18:04 +02:00
49 changed files with 2312 additions and 1248 deletions

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

7
.gitignore vendored
View File

@@ -1,8 +1,13 @@
result result
result-* result-*
**/__pycache__
dibbler.egg-info
dist dist
test.db test.db
.ruff_cache .ruff_cache
*.qcow2
dibbler/_version.py

View File

@@ -2,30 +2,55 @@
EDB-system for PVVVV EDB-system for PVVVV
## Hva er dette?
Dibbler er et system laget av PVVere for PVVere for å byttelåne både matvarer og godis.
Det er designet for en gammeldags VT terminal, og er laget for å være enkelt både å bruke og å hacke på.
Programmet er skrevet i Python, og bruker en sql database for å lagre data.
Samlespleiseboden er satt opp slik at folk kjøper inn varer, og får dibblerkreditt, og så kan man bruke
denne kreditten til å kjøpe ut andre varer. Det er ikke noen form for authentisering, så hele systemet er basert på tillit.
Det er anbefalt å koble en barkodeleser til systemet for å gjøre det enklere å både legge til og kjøpe varer.
## Kom i gang
Installer python, og lag og aktiver et venv. Installer så avhengighetene med `pip install`.
Deretter kan du kjøre programmet med
```console
python -m dibbler -c example-config.toml create-db
python -m dibbler -c example-config.toml seed-data
python -m dibbler -c example-config.toml loop
```
## Nix ## Nix
### Hvordan kjøre
`nix run github:Prograrmvarverkstedet/dibbler` > [!NOTE]
> Vi har skrevet nix-kode for å generere en QEMU-VM med tilnærmet produksjonsoppsett.
> Det kjører ikke nødvendigvis noen VM-er i produksjon, og ihvertfall ikke denne VM-en.
> Den er hovedsakelig laget for enkel interaktiv testing, og for å teste NixOS modulen.
Du kan enklest komme i gang med nix-utvikling ved å kjøre test 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 endre conf.py lokalt til å inneholde instillingene dine. **NB: Denne kommer til å ligge i nix storen.** nix run .# -- --config example-config.toml create-db
nix run .# -- --config example-config.toml seed-data
nix run .# -- --config example-config.toml loop
```
Du kan også endre hvilken conf.py som blir brukt direkte i pakken eller i modulen. ## Produksjonssetting
Se eksempelet for hvordan skrot er satt opp i flake.nix Se https://wiki.pvv.ntnu.no/wiki/Drift/Dibbler
### Bygge image for skrot
Skrot har et image definert i flake.nix:
1. endre conf.py
2. `nix build .#images.skrot`
3. ???
4. non-profit

13
conf.py
View File

@@ -1,13 +0,0 @@
db_url = "postgresql://robertem@127.0.0.1/pvvvv"
quit_allowed = True
stop_allowed = False
show_tracebacks = True
input_encoding = "utf8"
low_credit_warning_limit = -100
user_recent_transaction_limit = 100
# See https://pypi.org/project/brother_ql/ for label types
# Set rotate to False for endless labels
label_type = "62"
label_rotate = False

View File

@@ -1,4 +0,0 @@
{ pkgs ? import <nixos-unstable> { } }:
{
dibbler = pkgs.callPackage ./nix/dibbler.nix { };
}

View File

@@ -1,6 +1,56 @@
# This module is supposed to act as a singleton and be filled import os
# with config variables by cli.py import sys
import tomllib
from pathlib import Path
from typing import Any
import configparser from dibbler.lib.helpers import file_is_submissive_and_readable
config = configparser.ConfigParser() DEFAULT_CONFIG_PATH = Path("/etc/dibbler/dibbler.toml")
config: dict[str, dict[str, Any]] = {}
def load_config(config_path: Path | None = None) -> None:
global config
if config_path is not None:
with Path(config_path).open("rb") as file:
config = tomllib.load(file)
elif file_is_submissive_and_readable(DEFAULT_CONFIG_PATH):
with DEFAULT_CONFIG_PATH.open("rb") as file:
config = tomllib.load(file)
else:
print(
"Could not read config file, it was neither provided nor readable in default location",
file=sys.stderr,
)
sys.exit(1)
def config_db_string() -> str:
db_type = config["database"]["type"]
if db_type == "sqlite":
path = Path(config["database"]["sqlite"]["path"])
return f"sqlite:///{path.absolute()}"
if db_type == "postgresql":
host = config["database"]["postgresql"]["host"]
port = config["database"]["postgresql"].get("port", 5432)
username = config["database"]["postgresql"].get("username", "dibbler")
dbname = config["database"]["postgresql"].get("dbname", "dibbler")
if "password_file" in config["database"]["postgresql"]:
with Path(config["database"]["postgresql"]["password_file"]).open("r") as f:
password = f.read().strip()
elif "password" in config["database"]["postgresql"]:
password = config["database"]["postgresql"]["password"]
else:
password = ""
if host.startswith("/"):
return f"postgresql+psycopg2://{username}:{password}@/{dbname}?host={host}"
return f"postgresql+psycopg2://{username}:{password}@{host}:{port}/{dbname}"
print(f"Error: unknown database type '{db_type}'")
exit(1)

View File

@@ -1,7 +0,0 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from dibbler.conf import config
engine = create_engine(config.get("database", "url"))
Session = sessionmaker(bind=engine)

View File

@@ -1,70 +1,71 @@
import os # import os
from PIL import ImageFont # from PIL import ImageFont
from barcode.writer import ImageWriter, mm2px # from barcode.writer import ImageWriter, mm2px
from brother_ql.devicedependent import label_type_specs # from brother_ql.labels import ALL_LABELS
def px2mm(px, dpi=300): # def px2mm(px, dpi=300):
return (25.4 * px) / dpi # return (25.4 * px) / dpi
class BrotherLabelWriter(ImageWriter): # class BrotherLabelWriter(ImageWriter):
def __init__(self, typ="62", max_height=350, rot=False, text=None): # def __init__(self, typ="62", max_height=350, rot=False, text=None):
super(BrotherLabelWriter, self).__init__() # super(BrotherLabelWriter, self).__init__()
assert typ in label_type_specs # label = next([l for l in ALL_LABELS if l.identifier == typ])
self.rot = rot # assert label is not None
if self.rot: # self.rot = rot
self._h, self._w = label_type_specs[typ]["dots_printable"] # if self.rot:
if self._w == 0 or self._w > max_height: # self._h, self._w = label.dots_printable
self._w = min(max_height, self._h / 2) # if self._w == 0 or self._w > max_height:
else: # self._w = min(max_height, self._h / 2)
self._w, self._h = label_type_specs[typ]["dots_printable"] # else:
if self._h == 0 or self._h > max_height: # self._w, self._h = label.dots_printable
self._h = min(max_height, self._w / 2) # if self._h == 0 or self._h > max_height:
self._xo = 0.0 # self._h = min(max_height, self._w / 2)
self._yo = 0.0 # self._xo = 0.0
self._title = text # self._yo = 0.0
# self._title = text
def _init(self, code): # def _init(self, code):
self.text = None # self.text = None
super(BrotherLabelWriter, self)._init(code) # super(BrotherLabelWriter, self)._init(code)
def calculate_size(self, modules_per_line, number_of_lines, dpi=300): # def calculate_size(self, modules_per_line, number_of_lines, dpi=300):
x, y = super(BrotherLabelWriter, self).calculate_size( # x, y = super(BrotherLabelWriter, self).calculate_size(
modules_per_line, number_of_lines, dpi # modules_per_line, number_of_lines, dpi
) # )
self._xo = (px2mm(self._w) - px2mm(x)) / 2 # self._xo = (px2mm(self._w) - px2mm(x)) / 2
self._yo = px2mm(self._h) - px2mm(y) # self._yo = px2mm(self._h) - px2mm(y)
assert self._xo >= 0 # assert self._xo >= 0
assert self._yo >= 0 # assert self._yo >= 0
return int(self._w), int(self._h) # return int(self._w), int(self._h)
def _paint_module(self, xpos, ypos, width, color): # def _paint_module(self, xpos, ypos, width, color):
super(BrotherLabelWriter, self)._paint_module( # super(BrotherLabelWriter, self)._paint_module(
xpos + self._xo, ypos + self._yo, width, color # xpos + self._xo, ypos + self._yo, width, color
) # )
def _paint_text(self, xpos, ypos): # def _paint_text(self, xpos, ypos):
super(BrotherLabelWriter, self)._paint_text(xpos + self._xo, ypos + self._yo) # super(BrotherLabelWriter, self)._paint_text(xpos + self._xo, ypos + self._yo)
def _finish(self): # def _finish(self):
if self._title: # if self._title:
width = self._w + 1 # width = self._w + 1
height = 0 # height = 0
max_h = self._h - mm2px(self._yo, self.dpi) # max_h = self._h - mm2px(self._yo, self.dpi)
fs = int(max_h / 1.2) # fs = int(max_h / 1.2)
font_path = os.path.join( # font_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)), # os.path.dirname(os.path.realpath(__file__)),
"Stranger back in the Night.ttf", # "Stranger back in the Night.ttf",
) # )
font = ImageFont.truetype(font_path, 10) # font = ImageFont.truetype(font_path, 10)
while width > self._w or height > max_h: # while width > self._w or height > max_h:
font = ImageFont.truetype(font_path, fs) # font = ImageFont.truetype(font_path, fs)
width, height = font.getsize(self._title) # width, height = font.getsize(self._title)
fs -= 1 # fs -= 1
pos = ((self._w - width) // 2, 0 - (height // 8)) # pos = ((self._w - width) // 2, 0 - (height // 8))
self._draw.text(pos, self._title, font=font, fill=self.foreground) # self._draw.text(pos, self._title, font=font, fill=self.foreground)
return self._image # return self._image

View File

@@ -0,0 +1,108 @@
import sys
from pathlib import Path
from sqlalchemy import Engine, create_engine, inspect, select
from sqlalchemy.exc import DBAPIError, OperationalError
from sqlalchemy.orm import RelationshipProperty
from sqlalchemy.orm.clsregistry import _ModuleMarker
from dibbler.lib.helpers import file_is_submissive_and_readable
from dibbler.models import Base
def check_db_health(engine: Engine, verify_table_existence: bool = False) -> None:
dialect_name = getattr(engine.dialect, "name", "").lower()
if "postgres" in dialect_name:
check_postgres_ping(engine)
elif dialect_name == "sqlite":
check_sqlite_file(engine)
if verify_table_existence:
verify_tables_and_columns(engine)
def check_postgres_ping(engine: Engine) -> None:
try:
with engine.connect() as conn:
result = conn.execute(select(1))
scalar = result.scalar()
if scalar != 1 and scalar is not None:
print(
"Unexpected response from Postgres when running 'SELECT 1'",
file=sys.stderr,
)
sys.exit(1)
except (OperationalError, DBAPIError) as exc:
print(f"Failed to connect to Postgres database: {exc}", file=sys.stderr)
sys.exit(1)
def check_sqlite_file(engine: Engine) -> None:
db_path = engine.url.database
# Don't verify in-memory databases or empty paths
if db_path in (None, "", ":memory:"):
return
db_path = db_path.removeprefix("file:").removeprefix("sqlite:")
# Strip query parameters
if "?" in db_path:
db_path = db_path.split("?", 1)[0]
path = Path(db_path)
if not path.exists():
print(f"SQLite database file does not exist: {path}", file=sys.stderr)
sys.exit(1)
if not path.is_file():
print(f"SQLite database path is not a file: {path}", file=sys.stderr)
sys.exit(1)
if not file_is_submissive_and_readable(path):
print(f"SQLite database file is not submissive and readable: {path}", file=sys.stderr)
sys.exit(1)
return
def verify_tables_and_columns(engine: Engine) -> None:
iengine = inspect(engine)
errors = False
tables = iengine.get_table_names()
views = iengine.get_view_names()
tables.extend(views)
for _name, klass in Base.registry._class_registry.items():
if isinstance(klass, _ModuleMarker):
continue
table = klass.__tablename__
if table in tables:
columns = [c["name"] for c in iengine.get_columns(table)]
mapper = inspect(klass)
for column_prop in mapper.attrs:
if isinstance(column_prop, RelationshipProperty):
pass
else:
for column in column_prop.columns:
if not column.key in columns:
print(
f"Model '{klass}' declares column '{column.key}' which does not exist in database {engine}",
file=sys.stderr,
)
errors = True
else:
print(
f"Model '{klass}' declares table '{table}' which does not exist in database {engine}",
file=sys.stderr,
)
errors = True
if errors:
print("Have you remembered to run `dibbler create-db?", file=sys.stderr)
sys.exit(1)

View File

@@ -1,51 +1,69 @@
import pwd
import subprocess
import os import os
import pwd
import signal import signal
import subprocess
from collections.abc import Callable
from pathlib import Path
from typing import Any, Literal
from sqlalchemy import or_, and_ from sqlalchemy import and_, not_, or_
from sqlalchemy.orm import Session
from ..models import User, Product from ..models import Product, User
def search_user(string, session, ignorethisflag=None): def search_user(
string: str,
sql_session: Session,
# NOTE: search_products has 3 parameters, but this one only have 2.
# We need an extra parameter for polymorphic purposes.
ignore_this_flag: None = None,
) -> User | list[User] | None:
assert sql_session is not None
string = string.lower() string = string.lower()
exact_match = ( exact_match = (
session.query(User) sql_session.query(User)
.filter(or_(User.name == string, User.card == string, User.rfid == string)) .filter(or_(User.name == string, User.card == string, User.rfid == string))
.first() .first()
) )
if exact_match: if exact_match:
return exact_match return exact_match
user_list = ( return (
session.query(User) sql_session.query(User)
.filter( .filter(
or_( or_(
User.name.ilike(f"%{string}%"), User.name.ilike(f"%{string}%"),
User.card.ilike(f"%{string}%"), User.card.ilike(f"%{string}%"),
User.rfid.ilike(f"%{string}%"), User.rfid.ilike(f"%{string}%"),
) ),
) )
.all() .all()
) )
return user_list
def search_product(string, session, find_hidden_products=True): def search_product(
string: str,
sql_session: Session,
find_hidden_products: bool = True,
) -> Product | list[Product] | None:
assert sql_session is not None
if find_hidden_products: if find_hidden_products:
exact_match = ( exact_match = (
session.query(Product) sql_session.query(Product)
.filter(or_(Product.bar_code == string, Product.name == string)) .filter(or_(Product.bar_code == string, Product.name == string))
.first() .first()
) )
else: else:
exact_match = ( exact_match = (
session.query(Product) sql_session.query(Product)
.filter( .filter(
or_( or_(
Product.bar_code == string, Product.bar_code == string,
and_(Product.name == string, Product.hidden is False), and_(
) Product.name == string,
not_(Product.hidden),
),
),
) )
.first() .first()
) )
@@ -53,30 +71,33 @@ def search_product(string, session, find_hidden_products=True):
return exact_match return exact_match
if find_hidden_products: if find_hidden_products:
product_list = ( product_list = (
session.query(Product) sql_session.query(Product)
.filter( .filter(
or_( or_(
Product.bar_code.ilike(f"%{string}%"), Product.bar_code.ilike(f"%{string}%"),
Product.name.ilike(f"%{string}%"), Product.name.ilike(f"%{string}%"),
) ),
) )
.all() .all()
) )
else: else:
product_list = ( product_list = (
session.query(Product) sql_session.query(Product)
.filter( .filter(
or_( or_(
Product.bar_code.ilike(f"%{string}%"), Product.bar_code.ilike(f"%{string}%"),
and_(Product.name.ilike(f"%{string}%"), Product.hidden is False), and_(
) Product.name.ilike(f"%{string}%"),
not_(Product.hidden),
),
),
) )
.all() .all()
) )
return product_list return product_list
def system_user_exists(username): def system_user_exists(username: str) -> bool:
try: try:
pwd.getpwnam(username) pwd.getpwnam(username)
except KeyError: except KeyError:
@@ -87,7 +108,7 @@ def system_user_exists(username):
return True return True
def guess_data_type(string): def guess_data_type(string: str) -> Literal["card", "rfid", "bar_code", "username"] | None:
if string.startswith("ntnu") and string[4:].isdigit(): if string.startswith("ntnu") and string[4:].isdigit():
return "card" return "card"
if string.isdigit() and len(string) == 10: if string.isdigit() and len(string) == 10:
@@ -101,7 +122,11 @@ def guess_data_type(string):
return None return None
def argmax(d, all=False, value=None): def argmax(
d: dict[Any, Any],
all_: bool = False,
value: Callable[[Any], Any] | None = None,
) -> Any | list[Any] | None:
maxarg = None maxarg = None
if value is not None: if value is not None:
dd = d dd = d
@@ -111,12 +136,12 @@ def argmax(d, all=False, value=None):
for key in list(d.keys()): for key in list(d.keys()):
if maxarg is None or d[key] > d[maxarg]: if maxarg is None or d[key] > d[maxarg]:
maxarg = key maxarg = key
if all: if all_:
return [k for k in list(d.keys()) if d[k] == d[maxarg]] return [k for k in list(d.keys()) if d[k] == d[maxarg]]
return maxarg return maxarg
def less(string): def less(string: str) -> None:
""" """
Run less with string as input; wait until it finishes. Run less with string as input; wait until it finishes.
""" """
@@ -128,3 +153,13 @@ def less(string):
proc = subprocess.Popen("less", env=env, encoding="utf-8", stdin=subprocess.PIPE) proc = subprocess.Popen("less", env=env, encoding="utf-8", stdin=subprocess.PIPE)
proc.communicate(string) proc.communicate(string)
signal.signal(signal.SIGINT, int_handler) signal.signal(signal.SIGINT, int_handler)
def file_is_submissive_and_readable(file: Path) -> bool:
return file.is_file() and any(
[
file.stat().st_mode & 0o400 and file.stat().st_uid == os.getuid(),
file.stat().st_mode & 0o040 and file.stat().st_gid == os.getgid(),
file.stat().st_mode & 0o004,
],
)

View File

@@ -1,96 +1,95 @@
import os # import barcode
import datetime # from brother_ql.brother_ql_create import create_label
# from brother_ql.raster import BrotherQLRaster
# from brother_ql.backends import backend_factory
# from brother_ql.labels import ALL_LABELS
# from PIL import Image, ImageDraw, ImageFont
import barcode # from .barcode_helpers import BrotherLabelWriter
from brother_ql import BrotherQLRaster, create_label
from brother_ql.backends import backend_factory
from brother_ql.devicedependent import label_type_specs
from PIL import Image, ImageDraw, ImageFont
from .barcode_helpers import BrotherLabelWriter
def print_name_label( # def print_name_label(
text, # text,
margin=10, # margin=10,
rotate=False, # rotate=False,
label_type="62", # label_type="62",
printer_type="QL-700", # printer_type="QL-700",
): # ):
if not rotate: # label = next([l for l in ALL_LABELS if l.identifier == label_type])
width, height = label_type_specs[label_type]["dots_printable"] # if not rotate:
else: # width, height = label.dots_printable
height, width = label_type_specs[label_type]["dots_printable"] # else:
# height, width = label.dots_printable
font_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ChopinScript.ttf") # font_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "ChopinScript.ttf")
fs = 2000 # fs = 2000
tw, th = width, height # tw, th = width, height
if width == 0: # if width == 0:
while th + 2 * margin > height: # while th + 2 * margin > height:
font = ImageFont.truetype(font_path, fs) # font = ImageFont.truetype(font_path, fs)
tw, th = font.getsize(text) # tw, th = font.getsize(text)
fs -= 1 # fs -= 1
width = tw + 2 * margin # width = tw + 2 * margin
elif height == 0: # elif height == 0:
while tw + 2 * margin > width: # while tw + 2 * margin > width:
font = ImageFont.truetype(font_path, fs) # font = ImageFont.truetype(font_path, fs)
tw, th = font.getsize(text) # tw, th = font.getsize(text)
fs -= 1 # fs -= 1
height = th + 2 * margin # height = th + 2 * margin
else: # else:
while tw + 2 * margin > width or th + 2 * margin > height: # while tw + 2 * margin > width or th + 2 * margin > height:
font = ImageFont.truetype(font_path, fs) # font = ImageFont.truetype(font_path, fs)
tw, th = font.getsize(text) # tw, th = font.getsize(text)
fs -= 1 # fs -= 1
xp = (width // 2) - (tw // 2) # xp = (width // 2) - (tw // 2)
yp = (height // 2) - (th // 2) # yp = (height // 2) - (th // 2)
im = Image.new("RGB", (width, height), (255, 255, 255)) # im = Image.new("RGB", (width, height), (255, 255, 255))
dr = ImageDraw.Draw(im) # dr = ImageDraw.Draw(im)
dr.text((xp, yp), text, fill=(0, 0, 0), font=font) # dr.text((xp, yp), text, fill=(0, 0, 0), font=font)
now = datetime.datetime.now() # now = datetime.datetime.now()
date = now.strftime("%Y-%m-%d") # date = now.strftime("%Y-%m-%d")
dr.text((0, 0), date, fill=(0, 0, 0)) # dr.text((0, 0), date, fill=(0, 0, 0))
base_path = os.path.dirname(os.path.realpath(__file__)) # base_path = os.path.dirname(os.path.realpath(__file__))
fn = os.path.join(base_path, "bar_codes", text + ".png") # fn = os.path.join(base_path, "bar_codes", text + ".png")
im.save(fn, "PNG") # im.save(fn, "PNG")
print_image(fn, printer_type, label_type) # print_image(fn, printer_type, label_type)
def print_bar_code( # def print_bar_code(
barcode_value, # barcode_value,
barcode_text, # barcode_text,
barcode_type="ean13", # barcode_type="ean13",
rotate=False, # rotate=False,
printer_type="QL-700", # printer_type="QL-700",
label_type="62", # label_type="62",
): # ):
bar_coder = barcode.get_barcode_class(barcode_type) # bar_coder = barcode.get_barcode_class(barcode_type)
wr = BrotherLabelWriter(typ=label_type, rot=rotate, text=barcode_text, max_height=1000) # wr = BrotherLabelWriter(typ=label_type, rot=rotate, text=barcode_text, max_height=1000)
test = bar_coder(barcode_value, writer=wr) # test = bar_coder(barcode_value, writer=wr)
base_path = os.path.dirname(os.path.realpath(__file__)) # base_path = os.path.dirname(os.path.realpath(__file__))
fn = test.save(os.path.join(base_path, "bar_codes", barcode_value)) # fn = test.save(os.path.join(base_path, "bar_codes", barcode_value))
print_image(fn, printer_type, label_type) # print_image(fn, printer_type, label_type)
def print_image(fn, printer_type="QL-700", label_type="62"): # def print_image(fn, printer_type="QL-700", label_type="62"):
qlr = BrotherQLRaster(printer_type) # qlr = BrotherQLRaster(printer_type)
qlr.exception_on_warning = True # qlr.exception_on_warning = True
create_label(qlr, fn, label_type, threshold=70, cut=True) # create_label(qlr, fn, label_type, threshold=70, cut=True)
be = backend_factory("pyusb") # be = backend_factory("pyusb")
list_available_devices = be["list_available_devices"] # list_available_devices = be["list_available_devices"]
BrotherQLBackend = be["backend_class"] # BrotherQLBackend = be["backend_class"]
ad = list_available_devices() # ad = list_available_devices()
assert ad # assert ad
string_descr = ad[0]["string_descr"] # string_descr = ad[0]["string_descr"]
printer = BrotherQLBackend(string_descr) # printer = BrotherQLBackend(string_descr)
printer.write(qlr.data) # printer.write(qlr.data)

View File

@@ -3,18 +3,20 @@
import datetime import datetime
from collections import defaultdict from collections import defaultdict
from pathlib import Path
from sqlalchemy.orm import Session
from .helpers import *
from ..models import Transaction from ..models import Transaction
from ..db import Session from .helpers import *
def getUser(): def getUser(sql_session: Session) -> str:
assert sql_session is not None
while 1: while 1:
string = input("user? ") string = input("user? ")
session = Session() user = search_user(string, sql_session)
user = search_user(string, session) sql_session.close()
session.close()
if not isinstance(user, list): if not isinstance(user, list):
return user.name return user.name
i = 0 i = 0
@@ -37,12 +39,11 @@ def getUser():
return user[n].name return user[n].name
def getProduct(): def getProduct(sql_session: Session) -> str:
assert sql_session is not None
while 1: while 1:
string = input("product? ") string = input("product? ")
session = Session() product = search_product(string, sql_session)
product = search_product(string, session)
session.close()
if not isinstance(product, list): if not isinstance(product, list):
return product.name return product.name
i = 0 i = 0
@@ -76,12 +77,8 @@ class Database:
personDatoVerdi = defaultdict(list) # dict->array personDatoVerdi = defaultdict(list) # dict->array
personUkedagVerdi = defaultdict(list) personUkedagVerdi = defaultdict(list)
# for global # for global
personPosTransactions = ( personPosTransactions = {} # personPosTransactions[trygvrad] == 100 #trygvrad har lagt 100kr i boksen
{} personNegTransactions = {} # personNegTransactions[trygvrad» == 70 #trygvrad har tatt 70kr fra boksen
) # personPosTransactions[trygvrad] == 100 #trygvrad har lagt 100kr i boksen
personNegTransactions = (
{}
) # personNegTransactions[trygvrad» == 70 #trygvrad har tatt 70kr fra boksen
globalVareAntall = {} # globalVareAntall[Oreo] == 3 globalVareAntall = {} # globalVareAntall[Oreo] == 3
globalVareVerdi = {} # globalVareVerdi[Oreo] == 30 #[kr] globalVareVerdi = {} # globalVareVerdi[Oreo] == 30 #[kr]
globalPersonAntall = {} # globalPersonAntall[trygvrad] == 3 globalPersonAntall = {} # globalPersonAntall[trygvrad] == 3
@@ -93,7 +90,7 @@ class Database:
class InputLine: class InputLine:
def __init__(self, u, p, t): def __init__(self, u, p, t) -> None:
self.inputUser = u self.inputUser = u
self.inputProduct = p self.inputProduct = p
self.inputType = t self.inputType = t
@@ -126,17 +123,17 @@ def getInputType():
return int(inp) return int(inp)
def getProducts(products): def getProducts(products: str) -> list[tuple[str]]:
product = [] product = []
products = products.partition("¤") split_products = products.partition("¤")
product.append(products[0]) product.append(products[0])
while products[1] == "¤": while products[1] == "¤":
products = products[2].partition("¤") split_products = split_products[2].partition("¤")
product.append(products[0]) product.append(products[0])
return product return product
def getDateFile(date, inp): def getDateFile(date: str, inp: str) -> datetime.date:
try: try:
year = inp.partition("-") year = inp.partition("-")
month = year[2].partition("-") month = year[2].partition("-")
@@ -180,7 +177,7 @@ def addLineToDatabase(database, inputLine):
if abs(inputLine.price) > 90000: if abs(inputLine.price) > 90000:
return database return database
# fyller inn for varer # fyller inn for varer
if (not inputLine.product == "") and ( if (inputLine.product != "") and (
(inputLine.inputProduct == "") or (inputLine.inputProduct == inputLine.product) (inputLine.inputProduct == "") or (inputLine.inputProduct == inputLine.product)
): ):
database.varePersonAntall[inputLine.product][inputLine.user] = ( database.varePersonAntall[inputLine.product][inputLine.user] = (
@@ -194,7 +191,7 @@ def addLineToDatabase(database, inputLine):
database.vareUkedagAntall[inputLine.product][inputLine.weekday] += 1 database.vareUkedagAntall[inputLine.product][inputLine.weekday] += 1
# fyller inn for personer # fyller inn for personer
if (inputLine.inputUser == "") or (inputLine.inputUser == inputLine.user): if (inputLine.inputUser == "") or (inputLine.inputUser == inputLine.user):
if not inputLine.product == "": if inputLine.product != "":
database.personVareAntall[inputLine.user][inputLine.product] = ( database.personVareAntall[inputLine.user][inputLine.product] = (
database.personVareAntall[inputLine.user].setdefault(inputLine.product, 0) + 1 database.personVareAntall[inputLine.user].setdefault(inputLine.product, 0) + 1
) )
@@ -218,7 +215,7 @@ def addLineToDatabase(database, inputLine):
database.personNegTransactions[inputLine.user] = ( database.personNegTransactions[inputLine.user] = (
database.personNegTransactions.setdefault(inputLine.user, 0) + inputLine.price database.personNegTransactions.setdefault(inputLine.user, 0) + inputLine.price
) )
elif not (inputLine.inputType == 1): elif inputLine.inputType != 1:
database.globalVareAntall[inputLine.product] = ( database.globalVareAntall[inputLine.product] = (
database.globalVareAntall.setdefault(inputLine.product, 0) + 1 database.globalVareAntall.setdefault(inputLine.product, 0) + 1
) )
@@ -229,7 +226,7 @@ def addLineToDatabase(database, inputLine):
# fyller inn for global statistikk # fyller inn for global statistikk
if (inputLine.inputType == 3) or (inputLine.inputType == 4): if (inputLine.inputType == 3) or (inputLine.inputType == 4):
database.pengebeholdning[inputLine.dateNum] += inputLine.price database.pengebeholdning[inputLine.dateNum] += inputLine.price
if not (inputLine.product == ""): if inputLine.product != "":
database.globalPersonAntall[inputLine.user] = ( database.globalPersonAntall[inputLine.user] = (
database.globalPersonAntall.setdefault(inputLine.user, 0) + 1 database.globalPersonAntall.setdefault(inputLine.user, 0) + 1
) )
@@ -242,12 +239,12 @@ def addLineToDatabase(database, inputLine):
return database return database
def buildDatabaseFromDb(inputType, inputProduct, inputUser): def buildDatabaseFromDb(inputType, inputProduct, inputUser, sql_session: Session):
assert sql_session is not None
sdate = input("enter start date (yyyy-mm-dd)? ") sdate = input("enter start date (yyyy-mm-dd)? ")
edate = input("enter end date (yyyy-mm-dd)? ") edate = input("enter end date (yyyy-mm-dd)? ")
print("building database...") print("building database...")
session = Session() transaction_list = sql_session.query(Transaction).all()
transaction_list = session.query(Transaction).all()
inputLine = InputLine(inputUser, inputProduct, inputType) inputLine = InputLine(inputUser, inputProduct, inputType)
startDate = getDateDb(transaction_list[0].time, sdate) startDate = getDateDb(transaction_list[0].time, sdate)
endDate = getDateDb(transaction_list[-1].time, edate) endDate = getDateDb(transaction_list[-1].time, edate)
@@ -277,9 +274,9 @@ def buildDatabaseFromDb(inputType, inputProduct, inputUser):
inputLine.price = 0 inputLine.price = 0
print("saving as default.dibblerlog...", end=" ") print("saving as default.dibblerlog...", end=" ")
f = open("default.dibblerlog", "w") f = Path.open("default.dibblerlog", "w")
line_format = "%s|%s|%s|%s|%s|%s\n" line_format = "%s|%s|%s|%s|%s|%s\n"
transaction_list = session.query(Transaction).all() transaction_list = sql_session.query(Transaction).all()
for transaction in transaction_list: for transaction in transaction_list:
if transaction.purchase: if transaction.purchase:
products = "¤".join([ent.product.name for ent in transaction.purchase.entries]) products = "¤".join([ent.product.name for ent in transaction.purchase.entries])
@@ -294,8 +291,7 @@ def buildDatabaseFromDb(inputType, inputProduct, inputUser):
transaction.description, transaction.description,
) )
f.write(line.encode("utf8")) f.write(line.encode("utf8"))
session.close() f.close()
f.close
# bygg database.pengebeholdning # bygg database.pengebeholdning
if (inputType == 3) or (inputType == 4): if (inputType == 3) or (inputType == 4):
for i in range(inputLine.numberOfDays + 1): for i in range(inputLine.numberOfDays + 1):
@@ -315,7 +311,7 @@ def buildDatabaseFromFile(inputFile, inputType, inputProduct, inputUser):
sdate = input("enter start date (yyyy-mm-dd)? ") sdate = input("enter start date (yyyy-mm-dd)? ")
edate = input("enter end date (yyyy-mm-dd)? ") edate = input("enter end date (yyyy-mm-dd)? ")
f = open(inputFile) f = Path.open(inputFile)
try: try:
fileLines = f.readlines() fileLines = f.readlines()
finally: finally:
@@ -333,7 +329,7 @@ def buildDatabaseFromFile(inputFile, inputType, inputProduct, inputUser):
database.globalUkedagForbruk = [0] * 7 database.globalUkedagForbruk = [0] * 7
database.pengebeholdning = [0] * (inputLine.numberOfDays + 1) database.pengebeholdning = [0] * (inputLine.numberOfDays + 1)
for linje in fileLines: for linje in fileLines:
if not (linje[0] == "#") and not (linje == "\n"): if linje[0] != "#" and linje != "\n":
# henter dateNum, products, user, price # henter dateNum, products, user, price
restDel = linje.partition("|") restDel = linje.partition("|")
restDel = restDel[2].partition(" ") restDel = restDel[2].partition(" ")
@@ -363,7 +359,7 @@ def buildDatabaseFromFile(inputFile, inputType, inputProduct, inputUser):
return database, dateLine return database, dateLine
def printTopDict(dictionary, n, k): def printTopDict(dictionary: dict[str, Any], n: int, k: bool) -> None:
i = 0 i = 0
for key in sorted(dictionary, key=dictionary.get, reverse=k): for key in sorted(dictionary, key=dictionary.get, reverse=k):
print(key, ": ", dictionary[key]) print(key, ": ", dictionary[key])
@@ -373,7 +369,7 @@ def printTopDict(dictionary, n, k):
break break
def printTopDict2(dictionary, dictionary2, n): def printTopDict2(dictionary, dictionary2, n) -> None:
print("") print("")
print("product : price[kr] ( number )") print("product : price[kr] ( number )")
i = 0 i = 0
@@ -385,7 +381,7 @@ def printTopDict2(dictionary, dictionary2, n):
break break
def printWeekdays(week, days): def printWeekdays(week, days) -> None:
if week == [] or days == 0: if week == [] or days == 0:
return return
print( print(
@@ -408,10 +404,10 @@ def printWeekdays(week, days):
print("") print("")
def printBalance(database, user): def printBalance(database, user) -> None:
forbruk = 0 forbruk = 0
if user in database.personVareVerdi: if user in database.personVareVerdi:
forbruk = sum([i for i in list(database.personVareVerdi[user].values())]) forbruk = sum(database.personVareVerdi[user].values())
print("totalt kjøpt for: ", forbruk, end=" ") print("totalt kjøpt for: ", forbruk, end=" ")
if user in database.personNegTransactions: if user in database.personNegTransactions:
print("kr, totalt lagt til: ", -database.personNegTransactions[user], end=" ") print("kr, totalt lagt til: ", -database.personNegTransactions[user], end=" ")
@@ -423,14 +419,14 @@ def printBalance(database, user):
print("") print("")
def printUser(database, dateLine, user, n): def printUser(database, dateLine, user, n) -> None:
printTopDict2(database.personVareVerdi[user], database.personVareAntall[user], n) printTopDict2(database.personVareVerdi[user], database.personVareAntall[user], n)
print("\nforbruk per ukedag [kr/dag],", end=" ") print("\nforbruk per ukedag [kr/dag],", end=" ")
printWeekdays(database.personUkedagVerdi[user], len(dateLine)) printWeekdays(database.personUkedagVerdi[user], len(dateLine))
printBalance(database, user) printBalance(database, user)
def printProduct(database, dateLine, product, n): def printProduct(database, dateLine, product, n) -> None:
printTopDict(database.varePersonAntall[product], n, 1) printTopDict(database.varePersonAntall[product], n, 1)
print("\nforbruk per ukedag [antall/dag],", end=" ") print("\nforbruk per ukedag [antall/dag],", end=" ")
printWeekdays(database.vareUkedagAntall[product], len(dateLine)) printWeekdays(database.vareUkedagAntall[product], len(dateLine))
@@ -444,7 +440,7 @@ def printProduct(database, dateLine, product, n):
) )
def printGlobal(database, dateLine, n): def printGlobal(database, dateLine, n) -> None:
print("\nmest lagt til: ") print("\nmest lagt til: ")
printTopDict(database.personNegTransactions, n, 0) printTopDict(database.personNegTransactions, n, 0)
print("\nmest tatt fra:") print("\nmest tatt fra:")
@@ -458,9 +454,9 @@ def printGlobal(database, dateLine, n):
"Det er solgt varer til en verdi av: ", "Det er solgt varer til en verdi av: ",
sum(database.globalDatoForbruk), sum(database.globalDatoForbruk),
"kr, det er lagt til", "kr, det er lagt til",
-sum([i for i in list(database.personNegTransactions.values())]), -sum(database.personNegTransactions.values()),
"og tatt fra", "og tatt fra",
sum([i for i in list(database.personPosTransactions.values())]), sum(database.personPosTransactions.values()),
end=" ", end=" ",
) )
print( print(
@@ -470,23 +466,24 @@ def printGlobal(database, dateLine, n):
) )
def alt4menuTextOnly(database, dateLine): def alt4menuTextOnly(database, dateLine, sql_session: Session) -> None:
assert sql_session is not None
n = 10 n = 10
while 1: while 1:
print( print(
"\n1: user-statistics, 2: product-statistics, 3:global-statistics, n: adjust amount of data shown q:quit" "\n1: user-statistics, 2: product-statistics, 3:global-statistics, n: adjust amount of data shown q:quit",
) )
inp = input("") inp = input("")
if inp == "q": if inp == "q":
break break
elif inp == "1": if inp == "1":
try: try:
printUser(database, dateLine, getUser(), n) printUser(database, dateLine, getUser(sql_session), n)
except: except:
print("\n\nSomething is not right, (last date prior to first date?)") print("\n\nSomething is not right, (last date prior to first date?)")
elif inp == "2": elif inp == "2":
try: try:
printProduct(database, dateLine, getProduct(), n) printProduct(database, dateLine, getProduct(sql_session), n)
except: except:
print("\n\nSomething is not right, (last date prior to first date?)") print("\n\nSomething is not right, (last date prior to first date?)")
elif inp == "3": elif inp == "3":
@@ -498,15 +495,16 @@ def alt4menuTextOnly(database, dateLine):
n = int(input("set number to show ")) n = int(input("set number to show "))
def statisticsTextOnly(): def statisticsTextOnly(sql_session: Session) -> None:
assert sql_session is not None
inputType = 4 inputType = 4
product = "" product = ""
user = "" user = ""
print("\n0: from file, 1: from database, q:quit") print("\n0: from file, 1: from database, q:quit")
inp = input("") inp = input("")
if inp == "1": if inp == "1":
database, dateLine = buildDatabaseFromDb(inputType, product, user) database, dateLine = buildDatabaseFromDb(inputType, product, user, sql_session)
elif inp == "0" or inp == "": elif inp == "0" or inp == "":
database, dateLine = buildDatabaseFromFile("default.dibblerlog", inputType, product, user) database, dateLine = buildDatabaseFromFile("default.dibblerlog", inputType, product, user)
if not inp == "q": if inp != "q":
alt4menuTextOnly(database, dateLine) alt4menuTextOnly(database, dateLine, sql_session)

View File

@@ -1,6 +1,12 @@
import argparse import argparse
import sys
from pathlib import Path
from dibbler.conf import config from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from dibbler.conf import config_db_string, load_config
from dibbler.lib.check_db_health import check_db_health
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@@ -8,38 +14,78 @@ parser.add_argument(
"-c", "-c",
"--config", "--config",
help="Path to the config file", help="Path to the config file",
type=str, type=Path,
metavar="FILE",
required=False, required=False,
) )
parser.add_argument(
"-V",
"--version",
help="Show program version",
action="store_true",
default=False,
)
subparsers = parser.add_subparsers( subparsers = parser.add_subparsers(
title="subcommands", title="subcommands",
dest="subcommand", dest="subcommand",
required=True,
) )
subparsers.add_parser("loop", help="Run the dibbler loop") subparsers.add_parser("loop", help="Run the dibbler loop")
subparsers.add_parser("create-db", help="Create the database") subparsers.add_parser("create-db", help="Create the database")
subparsers.add_parser("slabbedasker", help="Find out who is slabbedasker") subparsers.add_parser("slabbedasker", help="Find out who is slabbedasker")
subparsers.add_parser("seed-data", help="Fill with mock data")
def main(): def main() -> None:
args = parser.parse_args() args = parser.parse_args()
config.read(args.config)
if args.version:
from ._version import commit_id, version
print(f"Dibbler version {version}, commit {commit_id if commit_id else '<unknown>'}")
return
if not args.subcommand:
parser.print_help()
sys.exit(1)
load_config(args.config)
engine = create_engine(config_db_string())
sql_session = Session(
engine,
expire_on_commit=False,
autocommit=False,
autoflush=False,
close_resets_only=True,
)
check_db_health(
engine,
verify_table_existence=args.subcommand != "create-db",
)
if args.subcommand == "loop": if args.subcommand == "loop":
import dibbler.subcommands.loop as loop import dibbler.subcommands.loop as loop
loop.main() loop.main(sql_session)
elif args.subcommand == "create-db": elif args.subcommand == "create-db":
import dibbler.subcommands.makedb as makedb import dibbler.subcommands.makedb as makedb
makedb.main() makedb.main(engine)
elif args.subcommand == "slabbedasker": elif args.subcommand == "slabbedasker":
import dibbler.subcommands.slabbedasker as slabbedasker import dibbler.subcommands.slabbedasker as slabbedasker
slabbedasker.main() slabbedasker.main(sql_session)
elif args.subcommand == "seed-data":
import dibbler.subcommands.seed_test_data as seed_test_data
seed_test_data.main(sql_session)
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -26,28 +26,28 @@ __all__ = [
from .addstock import AddStockMenu from .addstock import AddStockMenu
from .buymenu import BuyMenu from .buymenu import BuyMenu
from .editing import ( from .editing import (
AddUserMenu,
EditUserMenu,
AddProductMenu, AddProductMenu,
EditProductMenu, AddUserMenu,
AdjustStockMenu, AdjustStockMenu,
CleanupStockMenu, CleanupStockMenu,
EditProductMenu,
EditUserMenu,
) )
from .faq import FAQMenu from .faq import FAQMenu
from .helpermenus import Menu from .helpermenus import Menu
from .mainmenu import MainMenu from .mainmenu import MainMenu
from .miscmenus import ( from .miscmenus import (
ProductSearchMenu,
TransferMenu,
AdjustCreditMenu, AdjustCreditMenu,
UserListMenu,
ShowUserMenu,
ProductListMenu, ProductListMenu,
ProductSearchMenu,
ShowUserMenu,
TransferMenu,
UserListMenu,
) )
from .printermenu import PrintLabelMenu from .printermenu import PrintLabelMenu
from .stats import ( from .stats import (
ProductPopularityMenu,
ProductRevenueMenu,
BalanceMenu, BalanceMenu,
LoggedStatisticsMenu, LoggedStatisticsMenu,
ProductPopularityMenu,
ProductRevenueMenu,
) )

View File

@@ -1,6 +1,7 @@
from math import ceil from math import ceil
import sqlalchemy import sqlalchemy
from sqlalchemy.orm import Session
from dibbler.models import ( from dibbler.models import (
Product, Product,
@@ -9,12 +10,13 @@ from dibbler.models import (
Transaction, Transaction,
User, User,
) )
from .helpermenus import Menu from .helpermenus import Menu
class AddStockMenu(Menu): class AddStockMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Add stock and adjust credit", uses_db=True) super().__init__("Add stock and adjust credit", sql_session)
self.help_text = """ self.help_text = """
Enter what you have bought for PVVVV here, along with your user name and how Enter what you have bought for PVVVV here, along with your user name and how
much money you're due in credits for the purchase when prompted.\n""" much money you're due in credits for the purchase when prompted.\n"""
@@ -23,7 +25,7 @@ much money you're due in credits for the purchase when prompted.\n"""
self.products = {} self.products = {}
self.price = 0 self.price = 0
def _execute(self): def _execute(self, **_kwargs) -> bool | None:
questions = { questions = {
( (
False, False,
@@ -86,10 +88,10 @@ much money you're due in credits for the purchase when prompted.\n"""
self.perform_transaction() self.perform_transaction()
def complete_input(self): def complete_input(self) -> bool:
return bool(self.users) and len(self.products) and self.price return self.users is not None and len(self.products) > 0 and self.price > 0
def print_info(self): def print_info(self) -> None:
width = 6 + Product.name_length width = 6 + Product.name_length
print() print()
print(width * "-") print(width * "-")
@@ -109,7 +111,12 @@ much money you're due in credits for the purchase when prompted.\n"""
print(f"{self.products[product][0]}".rjust(width - len(product.name))) print(f"{self.products[product][0]}".rjust(width - len(product.name)))
print(width * "-") print(width * "-")
def add_thing_to_pending(self, thing, amount, price): def add_thing_to_pending(
self,
thing: User | Product,
amount: int,
price: int,
) -> None:
if isinstance(thing, User): if isinstance(thing, User):
self.users.append(thing) self.users.append(thing)
elif thing in list(self.products.keys()): elif thing in list(self.products.keys()):
@@ -119,7 +126,7 @@ much money you're due in credits for the purchase when prompted.\n"""
else: else:
self.products[thing] = [amount, price] self.products[thing] = [amount, price]
def perform_transaction(self): def perform_transaction(self) -> None:
print("Did you pay a different price?") print("Did you pay a different price?")
if self.confirm(">", default=False): if self.confirm(">", default=False):
self.price = self.input_int("How much did you pay?", 0, self.price, default=self.price) self.price = self.input_int("How much did you pay?", 0, self.price, default=self.price)
@@ -132,10 +139,11 @@ much money you're due in credits for the purchase when prompted.\n"""
old_price = product.price old_price = product.price
old_hidden = product.hidden old_hidden = product.hidden
product.price = int( product.price = int(
ceil(float(value) / (max(product.stock, 0) + self.products[product][0])) ceil(float(value) / (max(product.stock, 0) + self.products[product][0])),
) )
product.stock = max( product.stock = max(
self.products[product][0], product.stock + self.products[product][0] self.products[product][0],
product.stock + self.products[product][0],
) )
product.hidden = False product.hidden = False
print( print(
@@ -151,13 +159,14 @@ much money you're due in credits for the purchase when prompted.\n"""
PurchaseEntry(purchase, product, -self.products[product][0]) PurchaseEntry(purchase, product, -self.products[product][0])
purchase.perform_soft_purchase(-self.price, round_up=False) purchase.perform_soft_purchase(-self.price, round_up=False)
self.session.add(purchase) self.sql_session.add(purchase)
try: try:
self.session.commit() self.sql_session.commit()
print("Success! Transaction performed:") print("Success! Transaction performed:")
# self.print_info() # self.print_info()
for user in self.users: for user in self.users:
print(f"User {user.name}'s credit is now {user.credit:d}") print(f"User {user.name}'s credit is now {user.credit:d}")
except sqlalchemy.exc.SQLAlchemyError as e: except sqlalchemy.exc.SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not perform transaction: {e}") print(f"Could not perform transaction: {e}")

View File

@@ -1,4 +1,8 @@
from typing import Any
import sqlalchemy import sqlalchemy
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from dibbler.conf import config from dibbler.conf import config
from dibbler.models import ( from dibbler.models import (
@@ -13,10 +17,11 @@ from .helpermenus import Menu
class BuyMenu(Menu): class BuyMenu(Menu):
def __init__(self, session=None): superfast_mode: bool
Menu.__init__(self, "Buy", uses_db=True) purchase: Purchase
if session:
self.session = session def __init__(self, sql_session: Session) -> None:
super().__init__("Buy", sql_session)
self.superfast_mode = False self.superfast_mode = False
self.help_text = """ self.help_text = """
Each purchase may contain one or more products and one or more buyers. Each purchase may contain one or more products and one or more buyers.
@@ -28,7 +33,7 @@ addition, and you can type 'what' at any time to redisplay it.
When finished, write an empty line to confirm the purchase.\n""" When finished, write an empty line to confirm the purchase.\n"""
@staticmethod @staticmethod
def credit_check(user): def credit_check(user: User) -> bool:
""" """
:param user: :param user:
@@ -37,28 +42,32 @@ When finished, write an empty line to confirm the purchase.\n"""
""" """
assert isinstance(user, User) assert isinstance(user, User)
return user.credit > config.getint("limits", "low_credit_warning_limit") return user.credit > config["limits"]["low_credit_warning_limit"]
def low_credit_warning(self, user, timeout=False): def low_credit_warning(
self,
user: User,
timeout: bool = False,
) -> bool:
assert isinstance(user, User) assert isinstance(user, User)
print("***********************************************************************") print(r"***********************************************************************")
print("***********************************************************************") print(r"***********************************************************************")
print("") print(r"")
print("$$\ $$\ $$$$$$\ $$$$$$$\ $$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\\") print(r"$$\ $$\ $$$$$$\ $$$$$$$\ $$\ $$\ $$$$$$\ $$\ $$\ $$$$$$\\")
print("$$ | $\ $$ |$$ __$$\ $$ __$$\ $$$\ $$ |\_$$ _|$$$\ $$ |$$ __$$\\") print(r"$$ | $\ $$ |$$ __$$\ $$ __$$\ $$$\ $$ |\_$$ _|$$$\ $$ |$$ __$$\\")
print("$$ |$$$\ $$ |$$ / $$ |$$ | $$ |$$$$\ $$ | $$ | $$$$\ $$ |$$ / \__|") print(r"$$ |$$$\ $$ |$$ / $$ |$$ | $$ |$$$$\ $$ | $$ | $$$$\ $$ |$$ / \__|")
print("$$ $$ $$\$$ |$$$$$$$$ |$$$$$$$ |$$ $$\$$ | $$ | $$ $$\$$ |$$ |$$$$\\") print(r"$$ $$ $$\$$ |$$$$$$$$ |$$$$$$$ |$$ $$\$$ | $$ | $$ $$\$$ |$$ |$$$$\\")
print("$$$$ _$$$$ |$$ __$$ |$$ __$$< $$ \$$$$ | $$ | $$ \$$$$ |$$ |\_$$ |") print(r"$$$$ _$$$$ |$$ __$$ |$$ __$$< $$ \$$$$ | $$ | $$ \$$$$ |$$ |\_$$ |")
print("$$$ / \$$$ |$$ | $$ |$$ | $$ |$$ |\$$$ | $$ | $$ |\$$$ |$$ | $$ |") print(r"$$$ / \$$$ |$$ | $$ |$$ | $$ |$$ |\$$$ | $$ | $$ |\$$$ |$$ | $$ |")
print("$$ / \$$ |$$ | $$ |$$ | $$ |$$ | \$$ |$$$$$$\ $$ | \$$ |\$$$$$$ |") print(r"$$ / \$$ |$$ | $$ |$$ | $$ |$$ | \$$ |$$$$$$\ $$ | \$$ |\$$$$$$ |")
print("\__/ \__|\__| \__|\__| \__|\__| \__|\______|\__| \__| \______/") print(r"\__/ \__|\__| \__|\__| \__|\__| \__|\______|\__| \__| \______/")
print("") print(r"")
print("***********************************************************************") print(r"***********************************************************************")
print("***********************************************************************") print(r"***********************************************************************")
print("") print(r"")
print( print(
f"USER {user.name} HAS LOWER CREDIT THAN {config.getint('limits', 'low_credit_warning_limit'):d}." f"USER {user.name} HAS LOWER CREDIT THAN {config['limits']['low_credit_warning_limit']:d}.",
) )
print("THIS PURCHASE WILL CHARGE YOUR CREDIT TWICE AS MUCH.") print("THIS PURCHASE WILL CHARGE YOUR CREDIT TWICE AS MUCH.")
print("CONSIDER PUTTING MONEY IN THE BOX TO AVOID THIS.") print("CONSIDER PUTTING MONEY IN THE BOX TO AVOID THIS.")
@@ -68,10 +77,13 @@ When finished, write an empty line to confirm the purchase.\n"""
if timeout: if timeout:
print("THIS PURCHASE WILL AUTOMATICALLY BE PERFORMED IN 3 MINUTES!") print("THIS PURCHASE WILL AUTOMATICALLY BE PERFORMED IN 3 MINUTES!")
return self.confirm(prompt=">", default=True, timeout=180) return self.confirm(prompt=">", default=True, timeout=180)
else: return self.confirm(prompt=">", default=True)
return self.confirm(prompt=">", default=True)
def add_thing_to_purchase(self, thing, amount=1): def add_thing_to_purchase(
self,
thing: User | Product,
amount: int = 1,
) -> bool:
if isinstance(thing, User): if isinstance(thing, User):
if thing.is_anonymous(): if thing.is_anonymous():
print("---------------------------------------------") print("---------------------------------------------")
@@ -80,7 +92,10 @@ When finished, write an empty line to confirm the purchase.\n"""
print("---------------------------------------------") print("---------------------------------------------")
if not self.credit_check(thing): if not self.credit_check(thing):
if self.low_credit_warning(user=thing, timeout=self.superfast_mode): if self.low_credit_warning(
user=thing,
timeout=self.superfast_mode,
):
Transaction(thing, purchase=self.purchase, penalty=2) Transaction(thing, purchase=self.purchase, penalty=2)
else: else:
return False return False
@@ -95,7 +110,11 @@ When finished, write an empty line to confirm the purchase.\n"""
PurchaseEntry(self.purchase, thing, amount) PurchaseEntry(self.purchase, thing, amount)
return True return True
def _execute(self, initial_contents=None): def _execute(
self,
initial_contents: list[tuple[User | Product, int]] | None = None,
**_kwargs,
) -> bool:
self.print_header() self.print_header()
self.purchase = Purchase() self.purchase = Purchase()
self.exit_confirm_msg = None self.exit_confirm_msg = None
@@ -107,7 +126,7 @@ When finished, write an empty line to confirm the purchase.\n"""
for thing, num in initial_contents: for thing, num in initial_contents:
self.add_thing_to_purchase(thing, num) self.add_thing_to_purchase(thing, num)
def is_product(candidate): def is_product(candidate: Any) -> bool:
return isinstance(candidate[0], Product) return isinstance(candidate[0], Product)
if len(initial_contents) > 0 and all(map(is_product, initial_contents)): if len(initial_contents) > 0 and all(map(is_product, initial_contents)):
@@ -129,7 +148,7 @@ When finished, write an empty line to confirm the purchase.\n"""
True, True,
True, True,
): "Enter more products or users, or an empty line to confirm", ): "Enter more products or users, or an empty line to confirm",
}[(len(self.purchase.transactions) > 0, len(self.purchase.entries) > 0)] }[(len(self.purchase.transactions) > 0, len(self.purchase.entries) > 0)],
) )
# Read in a 'thing' (product or user): # Read in a 'thing' (product or user):
@@ -147,16 +166,16 @@ When finished, write an empty line to confirm the purchase.\n"""
if thing is None: if thing is None:
if not self.complete_input(): if not self.complete_input():
if self.confirm( if self.confirm(
"Not enough information entered. Abort purchase?", default=True "Not enough information entered. Abort purchase?",
default=True,
): ):
return False return False
continue continue
break break
else: # once we get something in the
# once we get something in the # purchase, we want to protect the
# purchase, we want to protect the # user from accidentally killing it
# user from accidentally killing it self.exit_confirm_msg = "Abort purchase?"
self.exit_confirm_msg = "Abort purchase?"
# Add the thing to our purchase object: # Add the thing to our purchase object:
if not self.add_thing_to_purchase(thing, amount=num): if not self.add_thing_to_purchase(thing, amount=num):
@@ -167,10 +186,11 @@ When finished, write an empty line to confirm the purchase.\n"""
break break
self.purchase.perform_purchase() self.purchase.perform_purchase()
self.session.add(self.purchase) self.sql_session.add(self.purchase)
try: try:
self.session.commit() self.sql_session.commit()
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store purchase: {e}") print(f"Could not store purchase: {e}")
else: else:
print("Purchase stored.") print("Purchase stored.")
@@ -178,9 +198,9 @@ When finished, write an empty line to confirm the purchase.\n"""
for t in self.purchase.transactions: for t in self.purchase.transactions:
if not t.user.is_anonymous(): if not t.user.is_anonymous():
print(f"User {t.user.name}'s credit is now {t.user.credit:d} kr") print(f"User {t.user.name}'s credit is now {t.user.credit:d} kr")
if t.user.credit < config.getint("limits", "low_credit_warning_limit"): if t.user.credit < config["limits"]["low_credit_warning_limit"]:
print( print(
f'USER {t.user.name} HAS LOWER CREDIT THAN {config.getint("limits", "low_credit_warning_limit"):d},', f"USER {t.user.name} HAS LOWER CREDIT THAN {config['limits']['low_credit_warning_limit']:d},",
"AND SHOULD CONSIDER PUTTING SOME MONEY IN THE BOX.", "AND SHOULD CONSIDER PUTTING SOME MONEY IN THE BOX.",
) )
@@ -189,10 +209,10 @@ When finished, write an empty line to confirm the purchase.\n"""
print("") print("")
return True return True
def complete_input(self): def complete_input(self) -> bool:
return self.purchase.is_complete() return self.purchase.is_complete()
def format_purchase(self): def format_purchase(self) -> str | None:
self.purchase.set_price() self.purchase.set_price()
transactions = self.purchase.transactions transactions = self.purchase.transactions
entries = self.purchase.entries entries = self.purchase.entries
@@ -204,7 +224,10 @@ When finished, write an empty line to confirm the purchase.\n"""
string += "(empty)" string += "(empty)"
else: else:
string += ", ".join( string += ", ".join(
[t.user.name + ("*" if not self.credit_check(t.user) else "") for t in transactions] [
t.user.name + ("*" if not self.credit_check(t.user) else "")
for t in transactions
],
) )
string += "\n products: " string += "\n products: "
if len(entries) == 0: if len(entries) == 0:
@@ -212,7 +235,7 @@ When finished, write an empty line to confirm the purchase.\n"""
else: else:
string += "\n " string += "\n "
string += "\n ".join( string += "\n ".join(
[f"{e.amount:d}x {e.product.name} ({e.product.price:d} kr)" for e in entries] [f"{e.amount:d}x {e.product.name} ({e.product.price:d} kr)" for e in entries],
) )
if len(transactions) > 1: if len(transactions) > 1:
string += f"\n price per person: {self.purchase.price_per_transaction():d} kr" string += f"\n price per person: {self.purchase.price_per_transaction():d} kr"
@@ -228,7 +251,7 @@ When finished, write an empty line to confirm the purchase.\n"""
return string return string
def print_purchase(self): def print_purchase(self) -> None:
info = self.format_purchase() info = self.format_purchase()
if info is not None: if info is not None:
self.set_context(info) self.set_context(info)

View File

@@ -1,6 +1,9 @@
import sqlalchemy import sqlalchemy
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session
from dibbler.models import Product, User
from dibbler.models import User, Product
from .helpermenus import Menu, Selector from .helpermenus import Menu, Selector
__all__ = [ __all__ = [
@@ -14,32 +17,48 @@ __all__ = [
class AddUserMenu(Menu): class AddUserMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Add user", uses_db=True) super().__init__("Add user", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
username = self.input_str( username = self.input_str(
"Username (should be same as PVV username)", "Username (should be same as PVV username)",
regex=User.name_re, regex=User.name_re,
length_range=(1, 10), length_range=(1, 10),
) )
cardnum = self.input_str("Card number (optional)", regex=User.card_re, length_range=(0, 10)) assert username is not None
cardnum = cardnum.lower()
rfid = self.input_str("RFID (optional)", regex=User.rfid_re, length_range=(0, 10)) cardnum = self.input_str(
"Card number (optional)",
regex=User.card_re,
length_range=(0, 10),
empty_string_is_none=True,
)
if cardnum is not None:
cardnum = cardnum.lower()
rfid = self.input_str(
"RFID (optional)",
regex=User.rfid_re,
length_range=(0, 10),
empty_string_is_none=True,
)
user = User(username, cardnum, rfid) user = User(username, cardnum, rfid)
self.session.add(user) self.sql_session.add(user)
try: try:
self.session.commit() self.sql_session.commit()
print(f"User {username} stored") print(f"User {username} stored")
except sqlalchemy.exc.IntegrityError as e: except IntegrityError as e:
self.sql_session.rollback()
print(f"Could not store user {username}: {e}") print(f"Could not store user {username}: {e}")
self.pause() self.pause()
class EditUserMenu(Menu): class EditUserMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Edit user", uses_db=True) super().__init__("Edit user", sql_session)
self.help_text = """ self.help_text = """
The only editable part of a user is its card number and rfid. The only editable part of a user is its card number and rfid.
@@ -47,7 +66,7 @@ First select an existing user, then enter a new card number for that
user, then rfid (write an empty line to remove the card number or rfid). user, then rfid (write an empty line to remove the card number or rfid).
""" """
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
user = self.input_user("User") user = self.input_user("User")
self.printc(f"Editing user {user.name}") self.printc(f"Editing user {user.name}")
@@ -69,43 +88,50 @@ user, then rfid (write an empty line to remove the card number or rfid).
empty_string_is_none=True, empty_string_is_none=True,
) )
try: try:
self.session.commit() self.sql_session.commit()
print(f"User {user.name} stored") print(f"User {user.name} stored")
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store user {user.name}: {e}") print(f"Could not store user {user.name}: {e}")
self.pause() self.pause()
class AddProductMenu(Menu): class AddProductMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Add product", uses_db=True) super().__init__("Add product", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
bar_code = self.input_str("Bar code", regex=Product.bar_code_re, length_range=(8, 13)) bar_code = self.input_str("Bar code", regex=Product.bar_code_re, length_range=(8, 13))
assert bar_code is not None
name = self.input_str("Name", regex=Product.name_re, length_range=(1, Product.name_length)) name = self.input_str("Name", regex=Product.name_re, length_range=(1, Product.name_length))
assert name is not None
price = self.input_int("Price", 1, 100000) price = self.input_int("Price", 1, 100000)
product = Product(bar_code, name, price) product = Product(bar_code, name, price)
self.session.add(product) self.sql_session.add(product)
try: try:
self.session.commit() self.sql_session.commit()
print(f"Product {name} stored") print(f"Product {name} stored")
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store product {name}: {e}") print(f"Could not store product {name}: {e}")
self.pause() self.pause()
class EditProductMenu(Menu): class EditProductMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Edit product", uses_db=True) super().__init__("Edit product", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
product = self.input_product("Product") product = self.input_product("Product")
self.printc(f"Editing product {product.name}") self.printc(f"Editing product {product.name}")
while True: while True:
selector = Selector( selector = Selector(
f"Do what with {product.name}?", f"Do what with {product.name}?",
sql_session=self.sql_session,
items=[ items=[
("name", "Edit name"), ("name", "Edit name"),
("price", "Edit price"), ("price", "Edit price"),
@@ -135,9 +161,10 @@ class EditProductMenu(Menu):
product.hidden = self.confirm(f"Hidden(currently {product.hidden})", default=False) product.hidden = self.confirm(f"Hidden(currently {product.hidden})", default=False)
elif what == "store": elif what == "store":
try: try:
self.session.commit() self.sql_session.commit()
print(f"Product {product.name} stored") print(f"Product {product.name} stored")
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store product {product.name}: {e}") print(f"Could not store product {product.name}: {e}")
self.pause() self.pause()
return return
@@ -149,10 +176,10 @@ class EditProductMenu(Menu):
class AdjustStockMenu(Menu): class AdjustStockMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Adjust stock", uses_db=True) super().__init__("Adjust stock", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
product = self.input_product("Product") product = self.input_product("Product")
@@ -168,10 +195,11 @@ class AdjustStockMenu(Menu):
product.stock += add_stock product.stock += add_stock
try: try:
self.session.commit() self.sql_session.commit()
print("Stock is now stored") print("Stock is now stored")
self.pause() self.pause()
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store stock: {e}") print(f"Could not store stock: {e}")
self.pause() self.pause()
return return
@@ -179,13 +207,13 @@ class AdjustStockMenu(Menu):
class CleanupStockMenu(Menu): class CleanupStockMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Stock Cleanup", uses_db=True) super().__init__("Stock Cleanup", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
products = self.session.query(Product).filter(Product.stock != 0).all() products = self.sql_session.query(Product).filter(Product.stock != 0).all()
print("Every product in stock will be printed.") print("Every product in stock will be printed.")
print("Entering no value will keep current stock or set it to 0 if it is negative.") print("Entering no value will keep current stock or set it to 0 if it is negative.")
@@ -199,15 +227,16 @@ class CleanupStockMenu(Menu):
for product in products: for product in products:
oldstock = product.stock oldstock = product.stock
product.stock = self.input_int(product.name, 0, 10000, default=max(0, oldstock)) product.stock = self.input_int(product.name, 0, 10000, default=max(0, oldstock))
self.session.add(product) self.sql_session.add(product)
if oldstock != product.stock: if oldstock != product.stock:
changed_products.append((product, oldstock)) changed_products.append((product, oldstock))
try: try:
self.session.commit() self.sql_session.commit()
print("New stocks are now stored.") print("New stocks are now stored.")
self.pause() self.pause()
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store stock: {e}") print(f"Could not store stock: {e}")
self.pause() self.pause()
return return

View File

@@ -1,129 +1,146 @@
# -*- coding: utf-8 -*- from textwrap import dedent
from .helpermenus import MessageMenu, Menu from sqlalchemy.orm import Session
from .helpermenus import Menu, MessageMenu
class FAQMenu(Menu): class FAQMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Frequently Asked Questions") super().__init__("Frequently Asked Questions", sql_session)
self.items = [ self.items = [
MessageMenu( MessageMenu(
"What is the meaning with this program?", "What is the meaning with this program?",
""" dedent("""
We want to avoid keeping lots of cash in PVVVV's money box and to We want to avoid keeping lots of cash in PVVVV's money box and to
make it easy to pay for stuff without using money. (Without using make it easy to pay for stuff without using money. (Without using
money each time, that is. You do of course have to pay for the things money each time, that is. You do of course have to pay for the things
you buy eventually). you buy eventually).
Dibbler stores a "credit" amount for each user. When you register a Dibbler stores a "credit" amount for each user. When you register a
purchase in Dibbler, this amount is decreased. To increase your purchase in Dibbler, this amount is decreased. To increase your
credit, purchase products for dibbler, and register them using "Add credit, purchase products for dibbler, and register them using "Add
stock and adjust credit". stock and adjust credit".
Alternatively, add money to the money box and use "Adjust credit" to Alternatively, add money to the money box and use "Adjust credit" to
tell Dibbler about it. tell Dibbler about it.
""", """),
sql_session,
), ),
MessageMenu( MessageMenu(
"Can I still pay for stuff using cash?", "Can I still pay for stuff using cash?",
""" dedent("""
Please put money in the money box and use "Adjust Credit" so that Please put money in the money box and use "Adjust Credit" so that
dibbler can keep track of credit and purchases.""", dibbler can keep track of credit and purchases.
"""),
sql_session,
),
MessageMenu(
"How do I exit from a submenu/dialog/thing?",
'Type "exit", "q", or ^d.',
sql_session,
), ),
MessageMenu("How do I exit from a submenu/dialog/thing?", 'Type "exit", "q", or ^d.'),
MessageMenu( MessageMenu(
'What does "." mean?', 'What does "." mean?',
""" dedent("""
The "." character, known as "full stop" or "period", is most often The "." character, known as "full stop" or "period", is most often
used to indicate the end of a sentence. used to indicate the end of a sentence.
It is also used by Dibbler to indicate that the program wants you to It is also used by Dibbler to indicate that the program wants you to
read some text before continuing. Whenever some output ends with a read some text before continuing. Whenever some output ends with a
line containing only a period, you should read the lines above and line containing only a period, you should read the lines above and
then press enter to continue. then press enter to continue.
""", """),
sql_session,
), ),
MessageMenu( MessageMenu(
"Why is the user interface so terribly unintuitive?", "Why is the user interface so terribly unintuitive?",
""" dedent("""
Answer #1: It is not. Answer #1: It is not.
Answer #2: We are trying to compete with PVV's microwave oven in Answer #2: We are trying to compete with PVV's microwave oven in
userfriendliness. userfriendliness.
Answer #3: YOU are unintuitive. Answer #3: YOU are unintuitive.
""", """),
sql_session,
), ),
MessageMenu( MessageMenu(
"Why is there no help command?", "Why is there no help command?",
'There is. Have you tried typing "help"?', 'There is. Have you tried typing "help"?',
sql_session,
), ),
MessageMenu( MessageMenu(
'Where are the easter eggs? I tried saying "moo", but nothing happened.', 'Where are the easter eggs? I tried saying "moo", but nothing happened.',
'Don\'t say "moo".', 'Don\'t say "moo".',
sql_session,
), ),
MessageMenu( MessageMenu(
"Why does the program speak English when all the users are Norwegians?", "Why does the program speak English when all the users are Norwegians?",
"Godt spørsmål. Det virket sikkert som en god idé der og da.", "Godt spørsmål. Det virket sikkert som en god idé der og da.",
sql_session,
), ),
MessageMenu( MessageMenu(
"Why does the screen have strange colours?", "Why does the screen have strange colours?",
""" dedent("""
Type "c" on the main menu to change the colours of the display, or Type "c" on the main menu to change the colours of the display, or
"cs" if you are a boring person. "cs" if you are a boring person.
""", """),
sql_session,
), ),
MessageMenu( MessageMenu(
"I found a bug; is there a reward?", "I found a bug; is there a reward?",
""" dedent("""
No. No.
But if you are certain that it is a bug, not a feature, then you But if you are certain that it is a bug, not a feature, then you
should fix it (or better: force someone else to do it). should fix it (or better: force someone else to do it).
Follow this procedure: Follow this procedure:
1. Check out the Dibbler code: https://github.com/Programvareverkstedet/dibbler 1. Check out the Dibbler code: https://github.com/Programvareverkstedet/dibbler
2. Fix the bug. 2. Fix the bug.
3. Check that the program still runs (and, preferably, that the bug is 3. Check that the program still runs (and, preferably, that the bug is
in fact fixed). in fact fixed).
4. Commit. 4. Commit.
5. Update the running copy from svn: 5. Update the running copy from svn:
$ su - $ su -
# su -l -s /bin/bash pvvvv # su -l -s /bin/bash pvvvv
$ cd dibbler $ cd dibbler
$ git pull $ git pull
6. Type "restart" in Dibbler to replace the running process by a new 6. Type "restart" in Dibbler to replace the running process by a new
one using the updated files. one using the updated files.
""", """),
sql_session,
), ),
MessageMenu( MessageMenu(
"My question isn't listed here; what do I do?", "My question isn't listed here; what do I do?",
""" dedent("""
DON'T PANIC. DON'T PANIC.
Follow this procedure: Follow this procedure:
1. Ask someone (or read the source code) and get an answer. 1. Ask someone (or read the source code) and get an answer.
2. Check out the Dibbler code: https://github.com/Programvareverkstedet/dibbler 2. Check out the Dibbler code: https://github.com/Programvareverkstedet/dibbler
3. Add your question (with answer) to the FAQ and commit. 3. Add your question (with answer) to the FAQ and commit.
4. Update the running copy from svn: 4. Update the running copy from svn:
$ su - $ su -
# su -l -s /bin/bash pvvvv # su -l -s /bin/bash pvvvv
$ cd dibbler $ cd dibbler
$ git pull $ git pull
5. Type "restart" in Dibbler to replace the running process by a new 5. Type "restart" in Dibbler to replace the running process by a new
one using the updated files. one using the updated files.
""", """),
sql_session,
), ),
] ]

View File

@@ -1,44 +1,64 @@
# -*- coding: utf-8 -*- from __future__ import annotations
import re import re
import sys import sys
from select import select from select import select
from typing import TYPE_CHECKING, Any, Literal, Self, TypeVar
from dibbler.db import Session
from dibbler.models import User
from dibbler.lib.helpers import ( from dibbler.lib.helpers import (
search_user,
search_product,
guess_data_type,
argmax, argmax,
guess_data_type,
search_product,
search_user,
) )
from dibbler.models import Product, User
exit_commands = ["exit", "abort", "quit", "bye", "eat flaming death", "q"] if TYPE_CHECKING:
help_commands = ["help", "?"] from collections.abc import Callable, Iterable
context_commands = ["what", "??"]
local_help_commands = ["help!", "???"] from sqlalchemy.orm import Session
exit_commands: list[str] = ["exit", "abort", "quit", "bye", "eat flaming death", "q"]
help_commands: list[str] = ["help", "?"]
context_commands: list[str] = ["what", "??"]
local_help_commands: list[str] = ["help!", "???"]
class ExitMenu(Exception): class ExitMenuException(Exception):
pass pass
class Menu(object): MenuItemType = TypeVar("MenuItemType", bound="Menu")
class Menu:
name: str
sql_session: Session
items: list[Menu | tuple[MenuItemType, str] | str]
prompt: str | None
end_prompt: str | None
return_index: bool
exit_msg: str | None
exit_confirm_msg: str | None
exit_disallowed_msg: str | None
help_text: str | None
context: str | None
def __init__( def __init__(
self, self,
name, name: str,
items=None, sql_session: Session,
prompt=None, items: list[Self | tuple[MenuItemType, str] | str] | None = None,
end_prompt="> ", prompt: str | None = None,
return_index=True, end_prompt: str | None = "> ",
exit_msg=None, return_index: bool = True,
exit_confirm_msg=None, exit_msg: str | None = None,
exit_disallowed_msg=None, exit_confirm_msg: str | None = None,
help_text=None, exit_disallowed_msg: str | None = None,
uses_db=False, help_text: str | None = None,
): ) -> None:
self.name = name self.name: str = name
self.sql_session: Session = sql_session
self.items = items if items is not None else [] self.items = items if items is not None else []
self.prompt = prompt self.prompt = prompt
self.end_prompt = end_prompt self.end_prompt = end_prompt
@@ -48,54 +68,61 @@ class Menu(object):
self.exit_disallowed_msg = exit_disallowed_msg self.exit_disallowed_msg = exit_disallowed_msg
self.help_text = help_text self.help_text = help_text
self.context = None self.context = None
self.uses_db = uses_db
self.session = None
def exit_menu(self): assert name is not None
assert self.sql_session is not None
def exit_menu(self) -> None:
if self.exit_disallowed_msg is not None: if self.exit_disallowed_msg is not None:
print(self.exit_disallowed_msg) print(self.exit_disallowed_msg)
return return
if self.exit_confirm_msg is not None: if self.exit_confirm_msg is not None:
if not self.confirm(self.exit_confirm_msg, default=True): if not self.confirm(self.exit_confirm_msg, default=True):
return return
raise ExitMenu() raise ExitMenuException()
def at_exit(self): def at_exit(self) -> None:
if self.exit_msg: if self.exit_msg:
print(self.exit_msg) print(self.exit_msg)
def set_context(self, string, display=True): def set_context(
self,
string: str | None,
display: bool = True,
) -> None:
self.context = string self.context = string
if self.context is not None and display: if self.context is not None and display:
print(self.context) print(self.context)
def add_to_context(self, string): def add_to_context(self, string: str) -> None:
self.context += string if self.context is not None:
self.context += string
else:
self.context = string
def printc(self, string): def printc(self, string: str) -> None:
print(string) print(string)
if self.context is None: if self.context is None:
self.context = string self.context = string
else: else:
self.context += "\n" + string self.context += "\n" + string
def show_context(self): def show_context(self) -> None:
print(self.header()) print(self.header())
if self.context is not None: if self.context is not None:
print(self.context) print(self.context)
def item_is_submenu(self, i): def item_is_submenu(self, i: int) -> bool:
return isinstance(self.items[i], Menu) return isinstance(self.items[i], Menu)
def item_name(self, i): def item_name(self, i: int) -> str:
if self.item_is_submenu(i): if self.item_is_submenu(i):
return self.items[i].name return self.items[i].name
elif isinstance(self.items[i], tuple): if isinstance(self.items[i], tuple):
return self.items[i][1] return self.items[i][1]
else: return self.items[i]
return self.items[i]
def item_value(self, i): def item_value(self, i: int) -> MenuItemType | int:
if isinstance(self.items[i], tuple): if isinstance(self.items[i], tuple):
return self.items[i][0] return self.items[i][0]
if self.return_index: if self.return_index:
@@ -104,14 +131,14 @@ class Menu(object):
def input_str( def input_str(
self, self,
prompt=None, prompt: str | None = None,
end_prompt=None, end_prompt: str | None = None,
regex=None, regex: str | None = None,
length_range=(None, None), length_range: tuple[int | None, int | None] = (None, None),
empty_string_is_none=False, empty_string_is_none: bool = False,
timeout=None, timeout: int | None = None,
default=None, default: str | None = None,
): ) -> str | None:
if prompt is None: if prompt is None:
prompt = self.prompt if self.prompt is not None else "" prompt = self.prompt if self.prompt is not None else ""
if default is not None: if default is not None:
@@ -168,7 +195,7 @@ class Menu(object):
): ):
if length_range[0] and length_range[1]: if length_range[0] and length_range[1]:
print( print(
f"Value must have length in range [{length_range[0]:d}, {length_range[1]:d}]" f"Value must have length in range [{length_range[0]:d}, {length_range[1]:d}]",
) )
elif length_range[0]: elif length_range[0]:
print(f"Value must have length at least {length_range[0]:d}") print(f"Value must have length at least {length_range[0]:d}")
@@ -177,7 +204,7 @@ class Menu(object):
continue continue
return result return result
def special_input_options(self, result): def special_input_options(self, result) -> bool:
""" """
Handles special, magic input for input_str Handles special, magic input for input_str
@@ -187,7 +214,7 @@ class Menu(object):
""" """
return False return False
def special_input_choice(self, in_str): def special_input_choice(self, in_str: str) -> bool:
""" """
Handle choices which are not simply menu items. Handle choices which are not simply menu items.
@@ -197,33 +224,39 @@ class Menu(object):
""" """
return False return False
def input_choice(self, number_of_choices, prompt=None, end_prompt=None): def input_choice(
self,
number_of_choices: int,
prompt: str | None = None,
end_prompt: str | None = None,
) -> int:
while True: while True:
result = self.input_str(prompt, end_prompt) result = self.input_str(prompt, end_prompt)
assert result is not None
if result == "": if result == "":
print("Please enter something") print("Please enter something")
else: else:
if result.isdigit(): if result.isdigit():
choice = int(result) choice = int(result)
if choice == 0 and 10 <= number_of_choices: if choice == 0 and number_of_choices >= 10:
return 10 return 10
if 0 < choice <= number_of_choices: if 0 < choice <= number_of_choices:
return choice return choice
if not self.special_input_choice(result): if not self.special_input_choice(result):
self.invalid_menu_choice(result) self.invalid_menu_choice(result)
def invalid_menu_choice(self, in_str): def invalid_menu_choice(self, in_str: str) -> None:
print("Please enter a valid choice.") print("Please enter a valid choice.")
def input_int( def input_int(
self, self,
prompt=None, prompt: str,
minimum=None, minimum: int | None = None,
maximum=None, maximum: int | None = None,
null_allowed=False, null_allowed: bool = False,
zero_allowed=True, zero_allowed: bool = True,
default=None, default: int | None = None,
): ) -> int | Literal[False]:
if minimum is not None and maximum is not None: if minimum is not None and maximum is not None:
end_prompt = f"({minimum}-{maximum})>" end_prompt = f"({minimum}-{maximum})>"
elif minimum is not None: elif minimum is not None:
@@ -234,7 +267,11 @@ class Menu(object):
end_prompt = "" end_prompt = ""
while True: while True:
result = self.input_str(prompt + end_prompt, default=default) result = self.input_str(
prompt + end_prompt,
default=str(default) if default is not None else None,
)
assert result is not None
if result == "" and null_allowed: if result == "" and null_allowed:
return False return False
try: try:
@@ -252,93 +289,115 @@ class Menu(object):
except ValueError: except ValueError:
print("Please enter an integer") print("Please enter an integer")
def input_user(self, prompt=None, end_prompt=None): def input_user(
self,
prompt: str | None = None,
end_prompt: str | None = None,
) -> User:
user = None user = None
while user is None: while user is None:
user = self.retrieve_user(self.input_str(prompt, end_prompt)) search_string = self.input_str(prompt, end_prompt)
assert search_string is not None
user = self.retrieve_user(search_string)
return user return user
def retrieve_user(self, search_str): def retrieve_user(self, search_str: str) -> User | None:
return self.search_ui(search_user, search_str, "user") return self.search_ui(search_user, search_str, "user")
def input_product(self, prompt=None, end_prompt=None): def input_product(
self,
prompt: str | None = None,
end_prompt: str | None = None,
) -> Product:
product = None product = None
while product is None: while product is None:
product = self.retrieve_product(self.input_str(prompt, end_prompt)) search_string = self.input_str(prompt, end_prompt)
assert search_string is not None
product = self.retrieve_product(search_string)
return product return product
def retrieve_product(self, search_str): def retrieve_product(self, search_str: str) -> Product | None:
return self.search_ui(search_product, search_str, "product") return self.search_ui(search_product, search_str, "product")
def input_thing( def input_thing(
self, self,
prompt=None, prompt: str | None = None,
end_prompt=None, end_prompt: str | None = None,
permitted_things=("user", "product"), permitted_things: Iterable[str] = ("user", "product"),
add_nonexisting=(), add_nonexisting: Iterable[str] = (),
empty_input_permitted=False, empty_input_permitted: bool = False,
find_hidden_products=True, find_hidden_products: bool = True,
): ) -> User | Product | None:
result = None result = None
while result is None: while result is None:
search_str = self.input_str(prompt, end_prompt) search_str = self.input_str(prompt, end_prompt)
assert search_str is not None
if search_str == "" and empty_input_permitted: if search_str == "" and empty_input_permitted:
return None return None
result = self.search_for_thing( result = self.search_for_thing(
search_str, permitted_things, add_nonexisting, find_hidden_products search_str,
permitted_things,
add_nonexisting,
find_hidden_products,
) )
return result return result
def input_multiple( def input_multiple(
self, self,
prompt=None, prompt: str | None = None,
end_prompt=None, end_prompt: str | None = None,
permitted_things=("user", "product"), permitted_things: Iterable[str] = ("user", "product"),
add_nonexisting=(), add_nonexisting: Iterable[str] = (),
empty_input_permitted=False, empty_input_permitted: bool = False,
find_hidden_products=True, find_hidden_products: bool = True,
): ) -> tuple[User | Product, int] | None:
result = None result = None
num = 0 num = 0
while result is None: while result is None:
search_str = self.input_str(prompt, end_prompt) search_str = self.input_str(prompt, end_prompt)
assert search_str is not None
search_lst = search_str.split(" ") search_lst = search_str.split(" ")
if search_str == "" and empty_input_permitted: if search_str == "" and empty_input_permitted:
return None return None
else: result = self.search_for_thing(
result = self.search_for_thing( search_str,
search_str, permitted_things, add_nonexisting, find_hidden_products permitted_things,
) add_nonexisting,
num = 1 find_hidden_products,
)
num = 1
if (result is None) and (len(search_lst) > 1): if (result is None) and (len(search_lst) > 1):
print('Interpreting input as "<number> <product>"') print('Interpreting input as "<number> <product>"')
try: try:
num = int(search_lst[0]) num = int(search_lst[0])
result = self.search_for_thing( result = self.search_for_thing(
" ".join(search_lst[1:]), " ".join(search_lst[1:]),
permitted_things, permitted_things,
add_nonexisting, add_nonexisting,
find_hidden_products, find_hidden_products,
) )
# Her kan det legges inn en except ValueError, # Her kan det legges inn en except ValueError,
# men da blir det fort mye plaging av brukeren # men da blir det fort mye plaging av brukeren
except Exception as e: except Exception as e:
print(e) print(e)
return result, num return result, num
def search_for_thing( def search_for_thing(
self, self,
search_str, search_str: str,
permitted_things=("user", "product"), permitted_things: Iterable[str] = ("user", "product"),
add_non_existing=(), add_non_existing: Iterable[str] = (),
find_hidden_products=True, find_hidden_products: bool = True,
): ) -> User | Product | None:
search_fun = {"user": search_user, "product": search_product} search_fun = {
"user": search_user,
"product": search_product,
}
results = {} results = {}
result_values = {} result_values = {}
for thing in permitted_things: for thing in permitted_things:
results[thing] = search_fun[thing](search_str, self.session, find_hidden_products) results[thing] = search_fun[thing](search_str, self.sql_session, find_hidden_products)
result_values[thing] = self.search_result_value(results[thing]) result_values[thing] = self.search_result_value(results[thing])
selected_thing = argmax(result_values) selected_thing = argmax(result_values)
if not results[selected_thing]: if not results[selected_thing]:
@@ -353,10 +412,14 @@ class Menu(object):
return self.search_add(search_str) return self.search_add(search_str)
# print('No match found for "%s".' % search_str) # print('No match found for "%s".' % search_str)
return None return None
return self.search_ui2(search_str, results[selected_thing], selected_thing) return self.search_ui2(
search_str,
results[selected_thing],
selected_thing,
)
@staticmethod @staticmethod
def search_result_value(result): def search_result_value(result) -> Literal[0, 1, 2, 3]:
if result is None: if result is None:
return 0 return 0
if not isinstance(result, list): if not isinstance(result, list):
@@ -367,18 +430,19 @@ class Menu(object):
return 2 return 2
return 1 return 1
def search_add(self, string): def search_add(self, string: str) -> User | None:
type_guess = guess_data_type(string) type_guess = guess_data_type(string)
if type_guess == "username": if type_guess == "username":
print(f'"{string}" looks like a username, but no such user exists.') print(f'"{string}" looks like a username, but no such user exists.')
if self.confirm(f"Create user {string}?"): if self.confirm(f"Create user {string}?"):
user = User(string, None) user = User(string, None)
self.session.add(user) self.sql_session.add(user)
return user return user
return None return None
if type_guess == "card": if type_guess == "card":
selector = Selector( selector = Selector(
f'"{string}" looks like a card number, but no user with that card number exists.', f'"{string}" looks like a card number, but no user with that card number exists.',
self.sql_session,
[ [
("create", f"Create user with card number {string}"), ("create", f"Create user with card number {string}"),
("set", f"Set card number of an existing user to {string}"), ("set", f"Set card number of an existing user to {string}"),
@@ -387,12 +451,14 @@ class Menu(object):
selection = selector.execute() selection = selector.execute()
if selection == "create": if selection == "create":
username = self.input_str( username = self.input_str(
"Username for new user (should be same as PVV username)", prompt="Username for new user (should be same as PVV username)",
User.name_re, end_prompt=None,
(1, 10), regex=User.name_re,
length_range=(1, 10),
) )
assert username is not None
user = User(username, string) user = User(username, string)
self.session.add(user) self.sql_session.add(user)
return user return user
if selection == "set": if selection == "set":
user = self.input_user("User to set card number for") user = self.input_user("User to set card number for")
@@ -405,11 +471,21 @@ class Menu(object):
print(f'"{string}" looks like the bar code for a product, but no such product exists.') print(f'"{string}" looks like the bar code for a product, but no such product exists.')
return None return None
def search_ui(self, search_fun, search_str, thing): def search_ui(
result = search_fun(search_str, self.session) self,
search_fun: Callable[[str, Session], list[Any] | Any],
search_str: str,
thing: str,
) -> Any:
result = search_fun(search_str, self.sql_session)
return self.search_ui2(search_str, result, thing) return self.search_ui2(search_str, result, thing)
def search_ui2(self, search_str, result, thing): def search_ui2(
self,
search_str: str,
result: list[Any] | Any,
thing: str,
) -> Any:
if not isinstance(result, list): if not isinstance(result, list):
return result return result
if len(result) == 0: if len(result) == 0:
@@ -429,25 +505,41 @@ class Menu(object):
else: else:
select_header = f'{len(result):d} {thing}s matching "{search_str}"' select_header = f'{len(result):d} {thing}s matching "{search_str}"'
select_items = result select_items = result
selector = Selector(select_header, items=select_items, return_index=False) selector = Selector(
select_header,
self.sql_session,
items=select_items,
return_index=False,
)
return selector.execute() return selector.execute()
@staticmethod def confirm(
def confirm(prompt, end_prompt=None, default=None, timeout=None): self,
return ConfirmMenu(prompt, end_prompt=None, default=default, timeout=timeout).execute() prompt: str,
end_prompt: str | None = None,
default: bool | None = None,
timeout: int | None = None,
) -> bool:
return ConfirmMenu(
self.sql_session,
prompt,
end_prompt=None,
default=default,
timeout=timeout,
).execute()
def header(self): def header(self) -> str:
return f"[{self.name}]" return f"[{self.name}]"
def print_header(self): def print_header(self) -> None:
print("") print("")
print(self.header()) print(self.header())
def pause(self): def pause(self) -> None:
self.input_str(".", end_prompt="") self.input_str(".", end_prompt="")
@staticmethod @staticmethod
def general_help(): def general_help() -> None:
print( print(
""" """
DIBBLER HELP DIBBLER HELP
@@ -470,10 +562,10 @@ class Menu(object):
of money PVVVV owes the user. This value decreases with the of money PVVVV owes the user. This value decreases with the
appropriate amount when you register a purchase, and you may increase appropriate amount when you register a purchase, and you may increase
it by putting money in the box and using the "Adjust credit" menu. it by putting money in the box and using the "Adjust credit" menu.
""" """,
) )
def local_help(self): def local_help(self) -> None:
if self.help_text is None: if self.help_text is None:
print("no help here") print("no help here")
else: else:
@@ -481,21 +573,15 @@ class Menu(object):
print(f"Help for {self.header()}:") print(f"Help for {self.header()}:")
print(self.help_text) print(self.help_text)
def execute(self, **kwargs): def execute(self, **_kwargs) -> MenuItemType | int | None:
self.set_context(None) self.set_context(None)
try: try:
if self.uses_db and not self.session: return self._execute(**_kwargs)
self.session = Session() except ExitMenuException:
return self._execute(**kwargs)
except ExitMenu:
self.at_exit() self.at_exit()
return None return None
finally:
if self.session is not None:
self.session.close()
self.session = None
def _execute(self, **kwargs): def _execute(self, **_kwargs) -> MenuItemType | int | None:
while True: while True:
self.print_header() self.print_header()
self.set_context(None) self.set_context(None)
@@ -514,12 +600,21 @@ class Menu(object):
class MessageMenu(Menu): class MessageMenu(Menu):
def __init__(self, name, message, pause_after_message=True): message: str
Menu.__init__(self, name) pause_after_message: bool
def __init__(
self,
name: str,
message: str,
sql_session: Session,
pause_after_message: bool = True,
) -> None:
super().__init__(name, sql_session)
self.message = message.strip() self.message = message.strip()
self.pause_after_message = pause_after_message self.pause_after_message = pause_after_message
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
print("") print("")
print(self.message) print(self.message)
@@ -528,10 +623,17 @@ class MessageMenu(Menu):
class ConfirmMenu(Menu): class ConfirmMenu(Menu):
def __init__(self, prompt="confirm? ", end_prompt=": ", default=None, timeout=0): def __init__(
Menu.__init__( self,
self, sql_session: Session,
prompt: str = "confirm? ",
end_prompt: str | None = ": ",
default: bool | None = None,
timeout: int | None = 0,
) -> None:
super().__init__(
"question", "question",
sql_session,
prompt=prompt, prompt=prompt,
end_prompt=end_prompt, end_prompt=end_prompt,
exit_disallowed_msg="Please answer yes or no", exit_disallowed_msg="Please answer yes or no",
@@ -539,45 +641,55 @@ class ConfirmMenu(Menu):
self.default = default self.default = default
self.timeout = timeout self.timeout = timeout
def _execute(self): def _execute(self, **_kwargs) -> bool:
options = {True: "[y]/n", False: "y/[n]", None: "y/n"}[self.default] options = {True: "[Y/n]", False: "[y/N]", None: "[y/n]"}[self.default]
while True: while True:
result = self.input_str( result = self.input_str(
f"{self.prompt} ({options})", end_prompt=": ", timeout=self.timeout f"{self.prompt} ({options})",
end_prompt=": ",
timeout=self.timeout,
) )
result = result.lower().strip() result = result.lower().strip()
if result in ["y", "yes"]: if result in ["y", "yes"]:
return True return True
elif result in ["n", "no"]: if result in ["n", "no"]:
return False return False
elif self.default is not None and result == "": if self.default is not None and result == "":
return self.default return self.default
else: print("Please answer yes or no")
print("Please answer yes or no")
class Selector(Menu): class Selector(Menu):
def __init__( def __init__(
self, self,
name, name: str,
items=None, sql_session: Session,
prompt="select", items: list[Self | tuple[MenuItemType, str] | str] | None = None,
return_index=True, prompt: str | None = "select",
exit_msg=None, return_index: bool = True,
exit_confirm_msg=None, exit_msg: str | None = None,
help_text=None, exit_confirm_msg: str | None = None,
): help_text: str | None = None,
) -> None:
if items is None: if items is None:
items = [] items = []
Menu.__init__(self, name, items, prompt, return_index=return_index, exit_msg=exit_msg) super().__init__(
name,
sql_session,
items,
prompt,
return_index=return_index,
exit_msg=exit_msg,
help_text=help_text,
)
def header(self): def header(self) -> str:
return self.name return self.name
def print_header(self): def print_header(self) -> None:
print(self.header()) print(self.header())
def local_help(self): def local_help(self) -> None:
if self.help_text is None: if self.help_text is None:
print("This is a selection menu. Enter one of the listed numbers, or") print("This is a selection menu. Enter one of the listed numbers, or")
print("'exit' to go out and do something else.") print("'exit' to go out and do something else.")

View File

@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
import os import os
import random import random
import sys import sys
from dibbler.db import Session from sqlalchemy.orm import Session
from .buymenu import BuyMenu from .buymenu import BuyMenu
from .faq import FAQMenu from .faq import FAQMenu
@@ -13,14 +12,17 @@ faq_commands = ["faq"]
restart_commands = ["restart"] restart_commands = ["restart"]
def restart(): def restart() -> None:
# Does not work if the script is not executable, or if it was # Does not work if the script is not executable, or if it was
# started by searching $PATH. # started by searching $PATH.
os.execv(sys.argv[0], sys.argv) os.execv(sys.argv[0], sys.argv)
class MainMenu(Menu): class MainMenu(Menu):
def special_input_choice(self, in_str): def __init__(self, sql_session: Session, **_kwargs) -> None:
super().__init__("Dibbler main menu", sql_session, **_kwargs)
def special_input_choice(self, in_str: str) -> bool:
mv = in_str.split() mv = in_str.split()
if len(mv) == 2 and mv[0].isdigit(): if len(mv) == 2 and mv[0].isdigit():
num = int(mv[0]) num = int(mv[0])
@@ -28,7 +30,7 @@ class MainMenu(Menu):
else: else:
num = 1 num = 1
item_name = in_str item_name = in_str
buy_menu = BuyMenu(Session()) buy_menu = BuyMenu(self.sql_session)
thing = buy_menu.search_for_thing(item_name, find_hidden_products=False) thing = buy_menu.search_for_thing(item_name, find_hidden_products=False)
if thing: if thing:
buy_menu.execute(initial_contents=[(thing, num)]) buy_menu.execute(initial_contents=[(thing, num)])
@@ -36,32 +38,26 @@ class MainMenu(Menu):
return True return True
return False return False
def special_input_options(self, result): def special_input_options(self, result: str) -> bool:
if result in faq_commands: if result in faq_commands:
FAQMenu().execute() FAQMenu(self.sql_session).execute()
return True return True
if result in restart_commands: if result in restart_commands:
if self.confirm("Restart Dibbler?"): if self.confirm("Restart Dibbler?"):
restart() restart()
pass pass
return True return True
elif result == "c": if result == "c":
os.system( print(f"\033[{random.randint(40, 49)};{random.randint(30, 37)};5m")
'echo -e "\033[' print("\033[2J")
+ str(random.randint(40, 49))
+ ";"
+ str(random.randint(30, 37))
+ ';5m"'
)
os.system("clear")
self.show_context() self.show_context()
return True return True
elif result == "cs": if result == "cs":
os.system('echo -e "\033[0m"') print("\033[0m")
os.system("clear") print("\033[2J")
self.show_context() self.show_context()
return True return True
return False return False
def invalid_menu_choice(self, in_str): def invalid_menu_choice(self, in_str: str) -> None:
print(self.show_context()) print(self.show_context())

View File

@@ -1,17 +1,19 @@
import sqlalchemy import sqlalchemy
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from dibbler.conf import config from dibbler.conf import config
from dibbler.models import Transaction, Product, User
from dibbler.lib.helpers import less from dibbler.lib.helpers import less
from dibbler.models import Product, Transaction, User
from .helpermenus import Menu, Selector from .helpermenus import Menu, Selector
class TransferMenu(Menu): class TransferMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Transfer credit between users", uses_db=True) super().__init__("Transfer credit between users", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
amount = self.input_int("Transfer amount", 1, 100000) amount = self.input_int("Transfer amount", 1, 100000)
self.set_context(f"Transferring {amount:d} kr", display=False) self.set_context(f"Transferring {amount:d} kr", display=False)
@@ -26,24 +28,25 @@ class TransferMenu(Menu):
t2 = Transaction(user2, -amount, f'transfer from {user1.name} "{comment}"') t2 = Transaction(user2, -amount, f'transfer from {user1.name} "{comment}"')
t1.perform_transaction() t1.perform_transaction()
t2.perform_transaction() t2.perform_transaction()
self.session.add(t1) self.sql_session.add(t1)
self.session.add(t2) self.sql_session.add(t2)
try: try:
self.session.commit() self.sql_session.commit()
print(f"Transferred {amount:d} kr from {user1} to {user2}") print(f"Transferred {amount:d} kr from {user1} to {user2}")
print(f"User {user1}'s credit is now {user1.credit:d} kr") print(f"User {user1}'s credit is now {user1.credit:d} kr")
print(f"User {user2}'s credit is now {user2.credit:d} kr") print(f"User {user2}'s credit is now {user2.credit:d} kr")
print(f"Comment: {comment}") print(f"Comment: {comment}")
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not perform transfer: {e}") print(f"Could not perform transfer: {e}")
# self.pause() # self.pause()
class ShowUserMenu(Menu): class ShowUserMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Show user", uses_db=True) super().__init__("Show user", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
user = self.input_user("User name, card number or RFID") user = self.input_user("User name, card number or RFID")
print(f"User name: {user.name}") print(f"User name: {user.name}")
@@ -52,11 +55,12 @@ class ShowUserMenu(Menu):
print(f"Credit: {user.credit} kr") print(f"Credit: {user.credit} kr")
selector = Selector( selector = Selector(
f"What do you want to know about {user.name}?", f"What do you want to know about {user.name}?",
self.sql_session,
items=[ items=[
( (
"transactions", "transactions",
"Recent transactions (List of last " "Recent transactions (List of last "
+ str(config.getint("limits", "user_recent_transaction_limit")) + str(config["limits"]["user_recent_transaction_limit"])
+ ")", + ")",
), ),
("products", f"Which products {user.name} has bought, and how many"), ("products", f"Which products {user.name} has bought, and how many"),
@@ -65,7 +69,7 @@ class ShowUserMenu(Menu):
) )
what = selector.execute() what = selector.execute()
if what == "transactions": if what == "transactions":
self.print_transactions(user, config.getint("limits", "user_recent_transaction_limit")) self.print_transactions(user, config["limits"]["user_recent_transaction_limit"])
elif what == "products": elif what == "products":
self.print_purchased_products(user) self.print_purchased_products(user)
elif what == "transactions-all": elif what == "transactions-all":
@@ -74,7 +78,7 @@ class ShowUserMenu(Menu):
print("What what?") print("What what?")
@staticmethod @staticmethod
def print_transactions(user, limit=None): def print_transactions(user: User, limit: int | None = None) -> None:
num_trans = len(user.transactions) num_trans = len(user.transactions)
if limit is None: if limit is None:
limit = num_trans limit = num_trans
@@ -87,10 +91,7 @@ class ShowUserMenu(Menu):
if t.purchase: if t.purchase:
products = [] products = []
for entry in t.purchase.entries: for entry in t.purchase.entries:
if abs(entry.amount) != 1: amount = f"{abs(entry.amount)}x " if abs(entry.amount) != 1 else ""
amount = f"{abs(entry.amount)}x "
else:
amount = ""
product = f"{amount}{entry.product.name}" product = f"{amount}{entry.product.name}"
products.append(product) products.append(product)
string += "purchase (" string += "purchase ("
@@ -98,13 +99,13 @@ class ShowUserMenu(Menu):
string += ")" string += ")"
if t.penalty > 1: if t.penalty > 1:
string += f" * {t.penalty:d}x penalty applied" string += f" * {t.penalty:d}x penalty applied"
else: elif t.description is not None:
string += t.description string += t.description
string += "\n" string += "\n"
less(string) less(string)
@staticmethod @staticmethod
def print_purchased_products(user): def print_purchased_products(user: User) -> None:
products = [] products = []
for ref in user.products: for ref in user.products:
product = ref.product product = ref.product
@@ -123,13 +124,13 @@ class ShowUserMenu(Menu):
class UserListMenu(Menu): class UserListMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "User list", uses_db=True) super().__init__("User list", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
user_list = self.session.query(User).all() user_list = self.sql_session.query(User).all()
total_credit = self.session.query(sqlalchemy.func.sum(User.credit)).first()[0] total_credit = self.sql_session.query(sqlalchemy.func.sum(User.credit)).first()[0]
line_format = "%-12s | %6s\n" line_format = "%-12s | %6s\n"
hline = "---------------------\n" hline = "---------------------\n"
@@ -144,10 +145,10 @@ class UserListMenu(Menu):
class AdjustCreditMenu(Menu): class AdjustCreditMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Adjust credit", uses_db=True) super().__init__("Adjust credit", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
user = self.input_user("User") user = self.input_user("User")
print(f"User {user.name}'s credit is {user.credit:d} kr") print(f"User {user.name}'s credit is {user.credit:d} kr")
@@ -164,24 +165,25 @@ class AdjustCreditMenu(Menu):
description = "manually adjusted credit" description = "manually adjusted credit"
transaction = Transaction(user, -amount, description) transaction = Transaction(user, -amount, description)
transaction.perform_transaction() transaction.perform_transaction()
self.session.add(transaction) self.sql_session.add(transaction)
try: try:
self.session.commit() self.sql_session.commit()
print(f"User {user.name}'s credit is now {user.credit:d} kr") print(f"User {user.name}'s credit is now {user.credit:d} kr")
except sqlalchemy.exc.SQLAlchemyError as e: except SQLAlchemyError as e:
self.sql_session.rollback()
print(f"Could not store transaction: {e}") print(f"Could not store transaction: {e}")
# self.pause() # self.pause()
class ProductListMenu(Menu): class ProductListMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Product list", uses_db=True) super().__init__("Product list", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
text = "" text = ""
product_list = ( product_list = (
self.session.query(Product) self.sql_session.query(Product)
.filter(Product.hidden.is_(False)) .filter(Product.hidden.is_(False))
.order_by(Product.stock.desc()) .order_by(Product.stock.desc())
) )
@@ -204,21 +206,22 @@ class ProductListMenu(Menu):
class ProductSearchMenu(Menu): class ProductSearchMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Product search", uses_db=True) super().__init__("Product search", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
self.set_context("Enter (part of) product name or bar code") self.set_context("Enter (part of) product name or bar code")
product = self.input_product() product = self.input_product()
print( print(
"Result: %s, price: %d kr, bar code: %s, stock: %d, hidden: %s" ", ".join(
% ( [
product.name, f"Result: {product.name}",
product.price, f"price: {product.price} kr",
product.bar_code, f"bar code: {product.bar_code}",
product.stock, f"stock: {product.stock}",
("Y" if product.hidden else "N"), f"hidden: {'Y' if product.hidden else 'N'}",
) ],
),
) )
# self.pause() # self.pause()

View File

@@ -1,45 +1,46 @@
import re from sqlalchemy.orm import Session
from dibbler.conf import config
from dibbler.models import Product, User
from dibbler.lib.printer_helpers import print_bar_code, print_name_label
# from dibbler.lib.printer_helpers import print_bar_code, print_name_label
from .helpermenus import Menu from .helpermenus import Menu
class PrintLabelMenu(Menu): class PrintLabelMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Print a label", uses_db=True) super().__init__("Print a label", sql_session)
self.help_text = """ self.help_text = """
Prints out a product bar code on the printer Prints out a product bar code on the printer
Put it up somewhere in the vicinity. Put it up somewhere in the vicinity.
""" """
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
thing = self.input_thing("Product/User") print("Printer menu is under renovation, please be patient")
if isinstance(thing, Product): return
if re.match(r"^[0-9]{13}$", thing.bar_code):
bar_type = "ean13" # thing = self.input_thing("Product/User")
elif re.match(r"^[0-9]{8}$", thing.bar_code):
bar_type = "ean8" # if isinstance(thing, Product):
else: # if re.match(r"^[0-9]{13}$", thing.bar_code):
bar_type = "code39" # bar_type = "ean13"
print_bar_code( # elif re.match(r"^[0-9]{8}$", thing.bar_code):
thing.bar_code, # bar_type = "ean8"
thing.name, # else:
barcode_type=bar_type, # bar_type = "code39"
rotate=config.getboolean("printer", "rotate"), # print_bar_code(
printer_type="QL-700", # thing.bar_code,
label_type=config.get("printer", "label_type"), # thing.name,
) # barcode_type=bar_type,
elif isinstance(thing, User): # rotate=config["printer"]["rotate"],
print_name_label( # printer_type="QL-700",
text=thing.name, # label_type=config.get("printer", "label_type"),
label_type=config.get("printer", "label_type"), # )
rotate=config.getboolean("printer", "rotate"), # elif isinstance(thing, User):
printer_type="QL-700", # print_name_label(
) # text=thing.name,
# label_type=config["printer"]["label_type"],
# rotate=config["printer"]["rotate"],
# printer_type="QL-700",
# )

View File

@@ -1,8 +1,9 @@
from sqlalchemy import desc, func from sqlalchemy import desc, func
from sqlalchemy.orm import Session
from dibbler.lib.helpers import less from dibbler.lib.helpers import less
from dibbler.models import PurchaseEntry, Product, User
from dibbler.lib.statistikkHelpers import statisticsTextOnly from dibbler.lib.statistikkHelpers import statisticsTextOnly
from dibbler.models import Product, PurchaseEntry, User
from .helpermenus import Menu from .helpermenus import Menu
@@ -15,14 +16,14 @@ __all__ = [
class ProductPopularityMenu(Menu): class ProductPopularityMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Products by popularity", uses_db=True) super().__init__("Products by popularity", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
text = "" text = ""
sub = ( sub = (
self.session.query( self.sql_session.query(
PurchaseEntry.product_id, PurchaseEntry.product_id,
func.sum(PurchaseEntry.amount).label("purchase_count"), func.sum(PurchaseEntry.amount).label("purchase_count"),
) )
@@ -31,8 +32,8 @@ class ProductPopularityMenu(Menu):
.subquery() .subquery()
) )
product_list = ( product_list = (
self.session.query(Product, sub.c.purchase_count) self.sql_session.query(Product, sub.c.purchase_count)
.outerjoin((sub, Product.product_id == sub.c.product_id)) .outerjoin(sub, Product.product_id == sub.c.product_id)
.order_by(desc(sub.c.purchase_count)) .order_by(desc(sub.c.purchase_count))
.filter(sub.c.purchase_count is not None) .filter(sub.c.purchase_count is not None)
.all() .all()
@@ -48,14 +49,14 @@ class ProductPopularityMenu(Menu):
class ProductRevenueMenu(Menu): class ProductRevenueMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Products by revenue", uses_db=True) super().__init__("Products by revenue", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
text = "" text = ""
sub = ( sub = (
self.session.query( self.sql_session.query(
PurchaseEntry.product_id, PurchaseEntry.product_id,
func.sum(PurchaseEntry.amount).label("purchase_count"), func.sum(PurchaseEntry.amount).label("purchase_count"),
) )
@@ -64,8 +65,8 @@ class ProductRevenueMenu(Menu):
.subquery() .subquery()
) )
product_list = ( product_list = (
self.session.query(Product, sub.c.purchase_count) self.sql_session.query(Product, sub.c.purchase_count)
.outerjoin((sub, Product.product_id == sub.c.product_id)) .outerjoin(sub, Product.product_id == sub.c.product_id)
.order_by(desc(sub.c.purchase_count * Product.price)) .order_by(desc(sub.c.purchase_count * Product.price))
.filter(sub.c.purchase_count is not None) .filter(sub.c.purchase_count is not None)
.all() .all()
@@ -86,22 +87,26 @@ class ProductRevenueMenu(Menu):
class BalanceMenu(Menu): class BalanceMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Total balance of PVVVV", uses_db=True) super().__init__("Total balance of PVVVV", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
self.print_header() self.print_header()
text = "" text = ""
total_value = 0 total_value = 0
product_list = self.session.query(Product).filter(Product.stock > 0).all() product_list = self.sql_session.query(Product).filter(Product.stock > 0).all()
for p in product_list: for p in product_list:
total_value += p.stock * p.price total_value += p.stock * p.price
total_positive_credit = ( total_positive_credit = (
self.session.query(func.sum(User.credit)).filter(User.credit > 0).first()[0] self.sql_session.query(func.coalesce(func.sum(User.credit), 0))
.filter(User.credit > 0)
.first()[0]
) )
total_negative_credit = ( total_negative_credit = (
self.session.query(func.sum(User.credit)).filter(User.credit < 0).first()[0] self.sql_session.query(func.coalesce(func.sum(User.credit), 0))
.filter(User.credit < 0)
.first()[0]
) )
total_credit = total_positive_credit + total_negative_credit total_credit = total_positive_credit + total_negative_credit
@@ -119,8 +124,8 @@ class BalanceMenu(Menu):
class LoggedStatisticsMenu(Menu): class LoggedStatisticsMenu(Menu):
def __init__(self): def __init__(self, sql_session: Session) -> None:
Menu.__init__(self, "Statistics from log", uses_db=True) super().__init__("Statistics from log", sql_session)
def _execute(self): def _execute(self, **_kwargs) -> None:
statisticsTextOnly() statisticsTextOnly(self.sql_session)

View File

@@ -18,7 +18,7 @@ class Base(DeclarativeBase):
"ck": "ck_%(table_name)s_`%(constraint_name)s`", "ck": "ck_%(table_name)s_`%(constraint_name)s`",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s", "pk": "pk_%(table_name)s",
} },
) )
@declared_attr.directive @declared_attr.directive
@@ -38,7 +38,7 @@ class Base(DeclarativeBase):
isinstance(v, InstrumentedList), isinstance(v, InstrumentedList),
isinstance(v, InstrumentedSet), isinstance(v, InstrumentedSet),
isinstance(v, InstrumentedDict), isinstance(v, InstrumentedDict),
] ],
) )
) )
return f"<{self.__class__.__name__}({columns})>" return f"<{self.__class__.__name__}({columns})>"

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
@@ -36,12 +37,19 @@ class Product(Base):
name_re = r".+" name_re = r".+"
name_length = 45 name_length = 45
def __init__(self, bar_code, name, price, stock=0, hidden=False): def __init__(
self,
bar_code: str,
name: str,
price: int,
stock: int = 0,
hidden: bool = False,
) -> None:
self.name = name self.name = name
self.bar_code = bar_code self.bar_code = bar_code
self.price = price self.price = price
self.stock = stock self.stock = stock
self.hidden = hidden self.hidden = hidden
def __str__(self): def __str__(self) -> str:
return self.name return self.name

View File

@@ -1,8 +1,8 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from datetime import datetime
import math import math
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
DateTime, DateTime,
@@ -29,23 +29,23 @@ class Purchase(Base):
price: Mapped[int] = mapped_column(Integer) price: Mapped[int] = mapped_column(Integer)
transactions: Mapped[set[Transaction]] = relationship( transactions: Mapped[set[Transaction]] = relationship(
back_populates="purchase", order_by="Transaction.user_name" back_populates="purchase",
order_by=Transaction.user_name,
) )
entries: Mapped[set[PurchaseEntry]] = relationship(back_populates="purchase") entries: Mapped[set[PurchaseEntry]] = relationship(back_populates="purchase")
def __init__(self): def __init__(self) -> None:
pass pass
def is_complete(self): def is_complete(self) -> bool:
return len(self.transactions) > 0 and len(self.entries) > 0 return len(self.transactions) > 0 and len(self.entries) > 0
def price_per_transaction(self, round_up=True): def price_per_transaction(self, round_up: bool = True) -> int:
if round_up: if round_up:
return int(math.ceil(float(self.price) / len(self.transactions))) return int(math.ceil(float(self.price) / len(self.transactions)))
else: return int(math.floor(float(self.price) / len(self.transactions)))
return int(math.floor(float(self.price) / len(self.transactions)))
def set_price(self, round_up=True): def set_price(self, round_up: bool = True) -> None:
self.price = 0 self.price = 0
for entry in self.entries: for entry in self.entries:
self.price += entry.amount * entry.product.price self.price += entry.amount * entry.product.price
@@ -53,16 +53,16 @@ class Purchase(Base):
for t in self.transactions: for t in self.transactions:
t.amount = self.price_per_transaction(round_up=round_up) t.amount = self.price_per_transaction(round_up=round_up)
def perform_purchase(self, ignore_penalty=False, round_up=True): def perform_purchase(self, ignore_penalty: bool = False, round_up: bool = True) -> None:
self.time = datetime.datetime.now() self.time = datetime.now()
self.set_price(round_up=round_up) self.set_price(round_up=round_up)
for t in self.transactions: for t in self.transactions:
t.perform_transaction(ignore_penalty=ignore_penalty) t.perform_transaction(ignore_penalty=ignore_penalty)
for entry in self.entries: for entry in self.entries:
entry.product.stock -= entry.amount entry.product.stock -= entry.amount
def perform_soft_purchase(self, price, round_up=True): def perform_soft_purchase(self, price: int, round_up: bool = True) -> None:
self.time = datetime.datetime.now() self.time = datetime.now()
self.price = price self.price = price
for t in self.transactions: for t in self.transactions:
t.amount = self.price_per_transaction(round_up=round_up) t.amount = self.price_per_transaction(round_up=round_up)

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
Integer,
ForeignKey, ForeignKey,
Integer,
) )
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped, Mapped,
@@ -27,10 +28,15 @@ class PurchaseEntry(Base):
product_id: Mapped[int] = mapped_column(ForeignKey("products.product_id")) product_id: Mapped[int] = mapped_column(ForeignKey("products.product_id"))
purchase_id: Mapped[int] = mapped_column(ForeignKey("purchases.id")) purchase_id: Mapped[int] = mapped_column(ForeignKey("purchases.id"))
product: Mapped[Product] = relationship(lazy="joined") product: Mapped[Product] = relationship(back_populates="purchases", lazy="joined")
purchase: Mapped[Purchase] = relationship(lazy="joined") purchase: Mapped[Purchase] = relationship(back_populates="entries", lazy="joined")
def __init__(self, purchase, product, amount): def __init__(
self,
purchase: Purchase,
product: Product,
amount: int,
) -> None:
self.product = product self.product = product
self.product_bar_code = product.bar_code self.product_bar_code = product.bar_code
self.purchase = purchase self.purchase = purchase

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
DateTime, DateTime,
@@ -18,8 +18,8 @@ from sqlalchemy.orm import (
from .Base import Base from .Base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from .User import User
from .Purchase import Purchase from .Purchase import Purchase
from .User import User
class Transaction(Base): class Transaction(Base):
@@ -36,17 +36,24 @@ class Transaction(Base):
purchase_id: Mapped[int | None] = mapped_column(ForeignKey("purchases.id")) purchase_id: Mapped[int | None] = mapped_column(ForeignKey("purchases.id"))
user: Mapped[User] = relationship(lazy="joined") user: Mapped[User] = relationship(lazy="joined")
purchase: Mapped[Purchase] = relationship(lazy="joined") purchase: Mapped[Purchase] = relationship(back_populates="transactions", lazy="joined")
def __init__(self, user, amount=0, description=None, purchase=None, penalty=1): def __init__(
self,
user: User,
amount: int = 0,
description: str | None = None,
purchase: Purchase | None = None,
penalty: int = 1,
) -> None:
self.user = user self.user = user
self.amount = amount self.amount = amount
self.description = description self.description = description
self.purchase = purchase self.purchase = purchase
self.penalty = penalty self.penalty = penalty
def perform_transaction(self, ignore_penalty=False): def perform_transaction(self, ignore_penalty: bool = False) -> None:
self.time = datetime.datetime.now() self.time = datetime.now()
if not ignore_penalty: if not ignore_penalty:
self.amount *= self.penalty self.amount *= self.penalty
self.user.credit -= self.amount self.user.credit -= self.amount

View File

@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
@@ -14,25 +15,34 @@ from sqlalchemy.orm import (
from .Base import Base from .Base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from .UserProducts import UserProducts
from .Transaction import Transaction from .Transaction import Transaction
from .UserProducts import UserProducts
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
name: Mapped[str] = mapped_column(String(10), primary_key=True) name: Mapped[str] = mapped_column(String(10), primary_key=True)
credit: Mapped[str] = mapped_column(Integer) credit: Mapped[int] = mapped_column(Integer)
card: Mapped[str | None] = mapped_column(String(20)) card: Mapped[str | None] = mapped_column(String(20))
rfid: Mapped[str | None] = mapped_column(String(20)) rfid: Mapped[str | None] = mapped_column(String(20))
products: Mapped[set[UserProducts]] = relationship(back_populates="user") products: Mapped[list[UserProducts]] = relationship(back_populates="user")
transactions: Mapped[set[Transaction]] = relationship(back_populates="user") transactions: Mapped[list[Transaction]] = relationship(
back_populates="user",
order_by="Transaction.time",
)
name_re = r"[a-z]+" name_re = r"[a-z]+"
card_re = r"(([Nn][Tt][Nn][Uu])?[0-9]+)?" card_re = r"(([Nn][Tt][Nn][Uu])?[0-9]+)?"
rfid_re = r"[0-9a-fA-F]*" rfid_re = r"[0-9a-fA-F]*"
def __init__(self, name, card, rfid=None, credit=0): def __init__(
self,
name: str,
card: str | None,
rfid: str | None = None,
credit: int = 0,
) -> None:
self.name = name self.name = name
if card == "": if card == "":
card = None card = None
@@ -42,8 +52,8 @@ class User(Base):
self.rfid = rfid self.rfid = rfid
self.credit = credit self.credit = credit
def __str__(self): def __str__(self) -> str:
return self.name return self.name
def is_anonymous(self): def is_anonymous(self) -> bool:
return self.card == "11122233" return self.card == "11122233"

View File

@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import ( from sqlalchemy import (
Integer,
ForeignKey, ForeignKey,
Integer,
) )
from sqlalchemy.orm import ( from sqlalchemy.orm import (
Mapped, Mapped,
@@ -14,8 +15,8 @@ from sqlalchemy.orm import (
from .Base import Base from .Base import Base
if TYPE_CHECKING: if TYPE_CHECKING:
from .User import User
from .Product import Product from .Product import Product
from .User import User
class UserProducts(Base): class UserProducts(Base):
@@ -27,5 +28,5 @@ class UserProducts(Base):
count: Mapped[int] = mapped_column(Integer) count: Mapped[int] = mapped_column(Integer)
sign: Mapped[int] = mapped_column(Integer) sign: Mapped[int] = mapped_column(Integer)
user: Mapped[User] = relationship() user: Mapped[User] = relationship(back_populates="products")
product: Mapped[Product] = relationship() product: Mapped[Product] = relationship(back_populates="users")

View File

@@ -1,11 +1,11 @@
__all__ = [ __all__ = [
'Base', "Base",
'Product', "Product",
'Purchase', "Purchase",
'PurchaseEntry', "PurchaseEntry",
'Transaction', "Transaction",
'User', "User",
'UserProducts', "UserProducts",
] ]
from .Base import Base from .Base import Base

View File

@@ -1,79 +1,111 @@
#!/usr/bin/python #!/usr/bin/python
# -*- coding: utf-8 -*-
import random import random
import sys import sys
import traceback import traceback
from signal import (
SIG_IGN,
SIGQUIT,
SIGTSTP,
)
from signal import (
signal as set_signal_handler,
)
from sqlalchemy.orm import Session
from ..conf import config from ..conf import config
from ..lib.helpers import * from ..menus import (
from ..menus import * AddProductMenu,
AddStockMenu,
AddUserMenu,
AdjustCreditMenu,
AdjustStockMenu,
BalanceMenu,
BuyMenu,
CleanupStockMenu,
EditProductMenu,
EditUserMenu,
FAQMenu,
LoggedStatisticsMenu,
MainMenu,
Menu,
PrintLabelMenu,
ProductListMenu,
ProductPopularityMenu,
ProductRevenueMenu,
ProductSearchMenu,
ShowUserMenu,
TransferMenu,
UserListMenu,
)
random.seed() random.seed()
def main(): def main(sql_session: Session) -> None:
if not config.getboolean("general", "stop_allowed"): if not config["general"]["stop_allowed"]:
signal.signal(signal.SIGQUIT, signal.SIG_IGN) set_signal_handler(SIGQUIT, SIG_IGN)
if not config.getboolean("general", "stop_allowed"): if not config["general"]["stop_allowed"]:
signal.signal(signal.SIGTSTP, signal.SIG_IGN) set_signal_handler(SIGTSTP, SIG_IGN)
main = MainMenu( main_menu = MainMenu(
"Dibbler main menu", sql_session,
items=[ items=[
BuyMenu(), BuyMenu(sql_session),
ProductListMenu(), ProductListMenu(sql_session),
ShowUserMenu(), ShowUserMenu(sql_session),
UserListMenu(), UserListMenu(sql_session),
AdjustCreditMenu(), AdjustCreditMenu(sql_session),
TransferMenu(), TransferMenu(sql_session),
AddStockMenu(), AddStockMenu(sql_session),
Menu( Menu(
"Add/edit", "Add/edit",
sql_session,
items=[ items=[
AddUserMenu(), AddUserMenu(sql_session),
EditUserMenu(), EditUserMenu(sql_session),
AddProductMenu(), AddProductMenu(sql_session),
EditProductMenu(), EditProductMenu(sql_session),
AdjustStockMenu(), AdjustStockMenu(sql_session),
CleanupStockMenu(), CleanupStockMenu(sql_session),
], ],
), ),
ProductSearchMenu(), ProductSearchMenu(sql_session),
Menu( Menu(
"Statistics", "Statistics",
sql_session,
items=[ items=[
ProductPopularityMenu(), ProductPopularityMenu(sql_session),
ProductRevenueMenu(), ProductRevenueMenu(sql_session),
BalanceMenu(), BalanceMenu(sql_session),
LoggedStatisticsMenu(), LoggedStatisticsMenu(sql_session),
], ],
), ),
FAQMenu(), FAQMenu(sql_session),
PrintLabelMenu(), PrintLabelMenu(sql_session),
], ],
exit_msg="happy happy joy joy", exit_msg="happy happy joy joy",
exit_confirm_msg="Really quit Dibbler?", exit_confirm_msg="Really quit Dibbler?",
) )
if not config.getboolean("general", "quit_allowed"): if not config["general"]["quit_allowed"]:
main.exit_disallowed_msg = "You can check out any time you like, but you can never leave." main_menu.exit_disallowed_msg = (
"You can check out any time you like, but you can never leave."
)
while True: while True:
# noinspection PyBroadException # noinspection PyBroadException
try: try:
main.execute() main_menu.execute()
except KeyboardInterrupt: except KeyboardInterrupt:
print("") print("")
print("Interrupted.") print("Interrupted.")
except: except:
print("Something went wrong.") print("Something went wrong.")
print(f"{sys.exc_info()[0]}: {sys.exc_info()[1]}") print(f"{sys.exc_info()[0]}: {sys.exc_info()[1]}")
if config.getboolean("general", "show_tracebacks"): if config["general"]["show_tracebacks"]:
traceback.print_tb(sys.exc_info()[2]) traceback.print_tb(sys.exc_info()[2])
else: else:
break break
print("Restarting main menu.") print("Restarting main menu.")
main_menu.sql_session.reset()
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,9 @@
#!/usr/bin/python #!/usr/bin/python
from sqlalchemy.engine import Engine
from dibbler.models import Base from dibbler.models import Base
from dibbler.db import engine
def main(): def main(engine: Engine) -> None:
Base.metadata.create_all(engine) Base.metadata.create_all(engine)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,46 @@
import json
from pathlib import Path
from sqlalchemy.orm import Session
from dibbler.models.Product import Product
from dibbler.models.User import User
JSON_FILE = Path(__file__).parent.parent.parent / "mock_data.json"
def clear_db(sql_session: Session) -> None:
sql_session.query(Product).delete()
sql_session.query(User).delete()
sql_session.commit()
def main(sql_session: Session) -> None:
clear_db(sql_session)
product_items = []
user_items = []
with Path.open(JSON_FILE) as f:
json_obj = json.load(f)
for product in json_obj["products"]:
product_item = Product(
bar_code=product["bar_code"],
name=product["name"],
price=product["price"],
stock=product["stock"],
)
product_items.append(product_item)
for user in json_obj["users"]:
user_item = User(
name=user["name"],
card=user["card"],
rfid=user["rfid"],
credit=user["credit"],
)
user_items.append(user_item)
sql_session.add_all(product_items)
sql_session.add_all(user_items)
sql_session.commit()

View File

@@ -1,18 +1,13 @@
#!/usr/bin/python #!/usr/bin/python
from dibbler.db import Session from sqlalchemy.orm import Session
from dibbler.models import User from dibbler.models import User
def main(): def main(sql_session: Session) -> None:
# Start an SQL session
session = Session()
# Let's find all users with a negative credit # Let's find all users with a negative credit
slabbedasker = session.query(User).filter(User.credit < 0).all() slabbedasker = sql_session.query(User).filter(User.credit < 0).all()
for slubbert in slabbedasker: for slubbert in slabbedasker:
print(f"{slubbert.name}, {slubbert.credit}") print(f"{slubbert.name}, {slubbert.credit}")
if __name__ == "__main__":
main()

View File

@@ -1,231 +1,231 @@
#! /usr/bin/env python # #! /usr/bin/env python
# TODO: fixme # # TODO: fixme
# -*- coding: UTF-8 -*- # # -*- coding: UTF-8 -*-
import matplotlib.pyplot as plt # import matplotlib.pyplot as plt
import matplotlib.dates as mdates # import matplotlib.dates as mdates
from dibbler.lib.statistikkHelpers import * # from dibbler.lib.statistikkHelpers import *
def getInputType(): # def getInputType():
inp = 0 # inp = 0
while not (inp == "1" or inp == "2" or inp == "3" or inp == "4"): # while not (inp == "1" or inp == "2" or inp == "3" or inp == "4"):
print("type 1 for user-statistics") # print("type 1 for user-statistics")
print("type 2 for product-statistics") # print("type 2 for product-statistics")
print("type 3 for global-statistics") # print("type 3 for global-statistics")
print("type 4 to enter loop-mode") # print("type 4 to enter loop-mode")
inp = input("") # inp = input("")
return int(inp) # return int(inp)
def getDateFile(date, n): # def getDateFile(date, n):
try: # try:
if n == 0: # if n == 0:
inp = input("start date? (yyyy-mm-dd) ") # inp = input("start date? (yyyy-mm-dd) ")
elif n == -1: # elif n == -1:
inp = input("end date? (yyyy-mm-dd) ") # inp = input("end date? (yyyy-mm-dd) ")
year = inp.partition("-") # year = inp.partition("-")
month = year[2].partition("-") # month = year[2].partition("-")
return datetime.date(int(year[0]), int(month[0]), int(month[2])) # return datetime.date(int(year[0]), int(month[0]), int(month[2]))
except: # except:
print("invalid date, setting start start date") # print("invalid date, setting start start date")
if n == 0: # if n == 0:
print("to date found on first line") # print("to date found on first line")
elif n == -1: # elif n == -1:
print("to date found on last line") # print("to date found on last line")
print(date) # print(date)
return datetime.date( # return datetime.date(
int(date.partition("-")[0]), # int(date.partition("-")[0]),
int(date.partition("-")[2].partition("-")[0]), # int(date.partition("-")[2].partition("-")[0]),
int(date.partition("-")[2].partition("-")[2]), # int(date.partition("-")[2].partition("-")[2]),
) # )
def dateToDateNumFile(date, startDate): # def dateToDateNumFile(date, startDate):
year = date.partition("-") # year = date.partition("-")
month = year[2].partition("-") # month = year[2].partition("-")
day = datetime.date(int(year[0]), int(month[0]), int(month[2])) # day = datetime.date(int(year[0]), int(month[0]), int(month[2]))
deltaDays = day - startDate # deltaDays = day - startDate
return int(deltaDays.days), day.weekday() # return int(deltaDays.days), day.weekday()
def getProducts(products): # def getProducts(products):
product = [] # product = []
products = products.partition("¤") # products = products.partition("¤")
product.append(products[0]) # product.append(products[0])
while products[1] == "¤": # while products[1] == "¤":
products = products[2].partition("¤") # products = products[2].partition("¤")
product.append(products[0]) # product.append(products[0])
return product # return product
def piePlot(dictionary, n): # def piePlot(dictionary, n):
keys = [] # keys = []
values = [] # values = []
i = 0 # i = 0
for key in sorted(dictionary, key=dictionary.get, reverse=True): # for key in sorted(dictionary, key=dictionary.get, reverse=True):
values.append(dictionary[key]) # values.append(dictionary[key])
if i < n: # if i < n:
keys.append(key) # keys.append(key)
i += 1 # i += 1
else: # else:
keys.append("") # keys.append("")
plt.pie(values, labels=keys) # plt.pie(values, labels=keys)
def datePlot(array, dateLine): # def datePlot(array, dateLine):
if not array == []: # if not array == []:
plt.bar(dateLine, array) # plt.bar(dateLine, array)
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%b")) # plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%b"))
def dayPlot(array, days): # def dayPlot(array, days):
if not array == []: # if not array == []:
for i in range(7): # for i in range(7):
array[i] = array[i] * 7.0 / days # array[i] = array[i] * 7.0 / days
plt.bar(list(range(7)), array) # plt.bar(list(range(7)), array)
plt.xticks( # plt.xticks(
list(range(7)), # list(range(7)),
[ # [
" mon", # " mon",
" tue", # " tue",
" wed", # " wed",
" thu", # " thu",
" fri", # " fri",
" sat", # " sat",
" sun", # " sun",
], # ],
) # )
def graphPlot(array, dateLine): # def graphPlot(array, dateLine):
if not array == []: # if not array == []:
plt.plot(dateLine, array) # plt.plot(dateLine, array)
plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%b")) # plt.gca().xaxis.set_major_formatter(mdates.DateFormatter("%b"))
def plotUser(database, dateLine, user, n): # def plotUser(database, dateLine, user, n):
printUser(database, dateLine, user, n) # printUser(database, dateLine, user, n)
plt.subplot(221) # plt.subplot(221)
piePlot(database.personVareAntall[user], n) # piePlot(database.personVareAntall[user], n)
plt.xlabel("antall varer kjøpt gjengitt i antall") # plt.xlabel("antall varer kjøpt gjengitt i antall")
plt.subplot(222) # plt.subplot(222)
datePlot(database.personDatoVerdi[user], dateLine) # datePlot(database.personDatoVerdi[user], dateLine)
plt.xlabel("penger brukt over dato") # plt.xlabel("penger brukt over dato")
plt.subplot(223) # plt.subplot(223)
piePlot(database.personVareVerdi[user], n) # piePlot(database.personVareVerdi[user], n)
plt.xlabel("antall varer kjøpt gjengitt i verdi") # plt.xlabel("antall varer kjøpt gjengitt i verdi")
plt.subplot(224) # plt.subplot(224)
dayPlot(database.personUkedagVerdi[user], len(dateLine)) # dayPlot(database.personUkedagVerdi[user], len(dateLine))
plt.xlabel("forbruk over ukedager") # plt.xlabel("forbruk over ukedager")
plt.show() # plt.show()
def plotProduct(database, dateLine, product, n): # def plotProduct(database, dateLine, product, n):
printProduct(database, dateLine, product, n) # printProduct(database, dateLine, product, n)
plt.subplot(221) # plt.subplot(221)
piePlot(database.varePersonAntall[product], n) # piePlot(database.varePersonAntall[product], n)
plt.xlabel("personer som har handler produktet") # plt.xlabel("personer som har handler produktet")
plt.subplot(222) # plt.subplot(222)
datePlot(database.vareDatoAntall[product], dateLine) # datePlot(database.vareDatoAntall[product], dateLine)
plt.xlabel("antall produkter handlet per dag") # plt.xlabel("antall produkter handlet per dag")
# plt.subplot(223) # # plt.subplot(223)
plt.subplot(224) # plt.subplot(224)
dayPlot(database.vareUkedagAntall[product], len(dateLine)) # dayPlot(database.vareUkedagAntall[product], len(dateLine))
plt.xlabel("antall over ukedager") # plt.xlabel("antall over ukedager")
plt.show() # plt.show()
def plotGlobal(database, dateLine, n): # def plotGlobal(database, dateLine, n):
printGlobal(database, dateLine, n) # printGlobal(database, dateLine, n)
plt.subplot(231) # plt.subplot(231)
piePlot(database.globalVareVerdi, n) # piePlot(database.globalVareVerdi, n)
plt.xlabel("varer kjøpt gjengitt som verdi") # plt.xlabel("varer kjøpt gjengitt som verdi")
plt.subplot(232) # plt.subplot(232)
datePlot(database.globalDatoForbruk, dateLine) # datePlot(database.globalDatoForbruk, dateLine)
plt.xlabel("forbruk over dato") # plt.xlabel("forbruk over dato")
plt.subplot(233) # plt.subplot(233)
graphPlot(database.pengebeholdning, dateLine) # graphPlot(database.pengebeholdning, dateLine)
plt.xlabel("pengebeholdning over tid (negativ verdi utgjør samlet kreditt)") # plt.xlabel("pengebeholdning over tid (negativ verdi utgjør samlet kreditt)")
plt.subplot(234) # plt.subplot(234)
piePlot(database.globalPersonForbruk, n) # piePlot(database.globalPersonForbruk, n)
plt.xlabel("penger brukt av personer") # plt.xlabel("penger brukt av personer")
plt.subplot(235) # plt.subplot(235)
dayPlot(database.globalUkedagForbruk, len(dateLine)) # dayPlot(database.globalUkedagForbruk, len(dateLine))
plt.xlabel("forbruk over ukedager") # plt.xlabel("forbruk over ukedager")
plt.show() # plt.show()
def alt4menu(database, dateLine, useDatabase): # def alt4menu(database, dateLine, useDatabase):
n = 10 # n = 10
while 1: # while 1:
print( # print(
"\n1: user-statistics, 2: product-statistics, 3:global-statistics, n: adjust amount of data shown q:quit" # "\n1: user-statistics, 2: product-statistics, 3:global-statistics, n: adjust amount of data shown q:quit"
) # )
try: # try:
inp = input("") # inp = input("")
except: # except:
continue # continue
if inp == "q": # if inp == "q":
break # break
elif inp == "1": # elif inp == "1":
if i == "0": # if i == "0":
user = input("input full username: ") # user = input("input full username: ")
else: # else:
user = getUser() # user = getUser()
plotUser(database, dateLine, user, n) # plotUser(database, dateLine, user, n)
elif inp == "2": # elif inp == "2":
if i == "0": # if i == "0":
product = input("input full product name: ") # product = input("input full product name: ")
else: # else:
product = getProduct() # product = getProduct()
plotProduct(database, dateLine, product, n) # plotProduct(database, dateLine, product, n)
elif inp == "3": # elif inp == "3":
plotGlobal(database, dateLine, n) # plotGlobal(database, dateLine, n)
elif inp == "n": # elif inp == "n":
try: # try:
n = int(input("set number to show ")) # n = int(input("set number to show "))
except: # except:
pass # pass
def main(): # def main():
inputType = getInputType() # inputType = getInputType()
i = input("0:fil, 1:database \n? ") # i = input("0:fil, 1:database \n? ")
if inputType == 1: # if inputType == 1:
if i == "0": # if i == "0":
user = input("input full username: ") # user = input("input full username: ")
else: # else:
user = getUser() # user = getUser()
product = "" # product = ""
elif inputType == 2: # elif inputType == 2:
if i == "0": # if i == "0":
product = input("input full product name: ") # product = input("input full product name: ")
else: # else:
product = getProduct() # product = getProduct()
user = "" # user = ""
else: # else:
product = "" # product = ""
user = "" # user = ""
if i == "0": # if i == "0":
inputFile = input("logfil? ") # inputFile = input("logfil? ")
if inputFile == "": # if inputFile == "":
inputFile = "default.dibblerlog" # inputFile = "default.dibblerlog"
database, dateLine = buildDatabaseFromFile(inputFile, inputType, product, user) # database, dateLine = buildDatabaseFromFile(inputFile, inputType, product, user)
else: # else:
database, dateLine = buildDatabaseFromDb(inputType, product, user) # database, dateLine = buildDatabaseFromDb(inputType, product, user)
if inputType == 1: # if inputType == 1:
plotUser(database, dateLine, user, 10) # plotUser(database, dateLine, user, 10)
if inputType == 2: # if inputType == 2:
plotProduct(database, dateLine, product, 10) # plotProduct(database, dateLine, product, 10)
if inputType == 3: # if inputType == 3:
plotGlobal(database, dateLine, 10) # plotGlobal(database, dateLine, 10)
if inputType == 4: # if inputType == 4:
alt4menu(database, dateLine, i) # alt4menu(database, dateLine, i)
if __name__ == "__main__": # if __name__ == "__main__":
main() # main()

View File

@@ -1,19 +0,0 @@
[general]
quit_allowed = true
stop_allowed = false
show_tracebacks = true
input_encoding = 'utf8'
[database]
; url = postgresql://robertem@127.0.0.1/pvvvv
url = sqlite:///test.db
[limits]
low_credit_warning_limit = -100
user_recent_transaction_limit = 100
# See https://pypi.org/project/brother_ql/ for label types
# Set rotate to False for endless labels
[printer]
label_type = "62"
label_rotate = false

35
example-config.toml Normal file
View File

@@ -0,0 +1,35 @@
[general]
quit_allowed = true
stop_allowed = false
show_tracebacks = true
input_encoding = 'utf8'
[database]
type = 'sqlite'
[database.sqlite]
path = 'test.db'
[database.postgresql]
host = 'localhost'
# host = '/run/postgresql'
port = 5432
username = 'dibbler'
dbname = 'dibbler'
# You can either specify a path to a file containing the password,
# or just specify the password directly
# password = 'superhemlig'
# password_file = '/var/lib/dibbler/db-password'
[limits]
low_credit_warning_limit = -100
user_recent_transaction_limit = 100
# See https://pypi.org/project/brother_ql/ for label types
# Set rotate to False for endless labels
[printer]
label_type = '62'
label_rotate = false

46
flake.lock generated
View File

@@ -1,57 +1,25 @@
{ {
"nodes": { "nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1693145325, "lastModified": 1764950072,
"narHash": "sha256-Gat9xskErH1zOcLjYMhSDBo0JTBZKfGS0xJlIRnj6Rc=", "narHash": "sha256-BmPWzogsG2GsXZtlT+MTcAWeDK5hkbGRZTeZNW42fwA=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "cddebdb60de376c1bdb7a4e6ee3d98355453fe56", "rev": "f61125a668a320878494449750330ca58b78c557",
"type": "github" "type": "github"
}, },
"original": { "original": {
"id": "nixpkgs", "owner": "NixOS",
"type": "indirect" "ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
} }
}, },
"root": { "root": {
"inputs": { "inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs" "nixpkgs": "nixpkgs"
} }
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
} }
}, },
"root": "root", "root": "root",

124
flake.nix
View File

@@ -1,77 +1,73 @@
{ {
description = "Dibbler samspleisebod"; description = "Dibbler samspleisebod";
inputs.flake-utils.url = "github:numtide/flake-utils"; inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { self, nixpkgs, flake-utils }: outputs = { self, nixpkgs }: let
flake-utils.lib.eachDefaultSystem (system: let inherit (nixpkgs) lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = f: lib.genAttrs systems (system: let
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
in { in f system pkgs);
packages = { in {
default = self.packages.${system}.dibbler; apps = let
dibbler = pkgs.callPackage ./nix/dibbler.nix { mkApp = program: description: {
python3Packages = pkgs.python311Packages; type = "app";
program = toString program;
meta = {
inherit description;
};
}; };
}; mkVm = name: mkApp "${self.nixosConfigurations.${name}.config.system.build.vm}/bin/run-nixos-vm";
in forAllSystems (system: pkgs: {
apps = {
default = self.apps.${system}.dibbler; default = self.apps.${system}.dibbler;
dibbler = flake-utils.lib.mkApp { dibbler = let
drv = self.packages.${system}.dibbler; app = pkgs.writeShellApplication {
}; name = "dibbler-with-default-config";
}; runtimeInputs = [ self.packages.${system}.dibbler ];
text = ''
dibbler -c ${./example-config.toml} "$@"
'';
};
in mkApp (lib.getExe app) "Run the dibbler cli with its default config against an SQLite database";
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";
});
devShells = {
default = self.devShells.${system}.dibbler;
dibbler = pkgs.mkShell {
packages = with pkgs; [
python311Packages.black
ruff
];
};
};
})
//
{
# Note: using the module requires that you have applied the
# overlay first
nixosModules.default = import ./nix/module.nix; nixosModules.default = import ./nix/module.nix;
images.skrot = self.nixosConfigurations.skrot.config.system.build.sdImage; nixosConfigurations = {
vm = import ./nix/nixos-configurations/vm.nix { inherit self nixpkgs; };
nixosConfigurations.skrot = nixpkgs.lib.nixosSystem { vm-non-kiosk = import ./nix/nixos-configurations/vm-non-kiosk.nix { inherit self nixpkgs; };
system = "aarch64-linux";
modules = [
(nixpkgs + "/nixos/modules/installer/sd-card/sd-image-aarch64.nix")
self.nixosModules.default
({...}: {
system.stateVersion = "22.05";
networking = {
hostName = "skrot";
domain = "pvv.ntnu.no";
nameservers = [ "129.241.0.200" "129.241.0.201" ];
defaultGateway = "129.241.210.129";
interfaces.eth0 = {
useDHCP = false;
ipv4.addresses = [{
address = "129.241.210.235";
prefixLength = 25;
}];
};
};
# services.resolved.enable = true;
# systemd.network.enable = true;
# systemd.network.networks."30-network" = {
# matchConfig.Name = "*";
# DHCP = "no";
# address = [ "129.241.210.235/25" ];
# gateway = [ "129.241.210.129" ];
# };
})
];
}; };
overlays = {
default = self.overlays.dibbler;
dibbler = final: prev: {
inherit (self.packages.${prev.stdenv.hostPlatform.system}) dibbler;
};
};
devShells = forAllSystems (system: pkgs: {
default = self.devShells.${system}.dibbler;
dibbler = pkgs.callPackage ./nix/shell.nix {
python = pkgs.python313;
};
});
packages = forAllSystems (system: pkgs: {
default = self.packages.${system}.dibbler;
dibbler = pkgs.callPackage ./nix/package.nix {
python3Packages = pkgs.python313Packages;
inherit (self) sourceInfo;
};
});
}; };
} }

76
mock_data.json Normal file
View File

@@ -0,0 +1,76 @@
{
"products": [
{
"product_id": 1,
"bar_code": "1234567890123",
"name": "Wireless Mouse",
"price": 2999,
"stock": 150,
"hidden": false
},
{
"product_id": 2,
"bar_code": "9876543210987",
"name": "Mechanical Keyboard",
"price": 5999,
"stock": 75,
"hidden": false
},
{
"product_id": 3,
"bar_code": "1112223334445",
"name": "Gaming Monitor",
"price": 19999,
"stock": 20,
"hidden": false
},
{
"product_id": 4,
"bar_code": "5556667778889",
"name": "USB-C Docking Station",
"price": 8999,
"stock": 50,
"hidden": true
},
{
"product_id": 5,
"bar_code": "4445556667771",
"name": "Noise Cancelling Headphones",
"price": 12999,
"stock": 30,
"hidden": true
}
],
"users": [
{
"name": "Albert",
"credit": 42069,
"card": "NTU12345678",
"rfid": "a1b2c3d4e5"
},
{
"name": "lorem",
"credit": 2000,
"card": "9876543210",
"rfid": "f6e7d8c9b0"
},
{
"name": "ibsum",
"credit": 1000,
"card": "11122233",
"rfid": ""
},
{
"name": "dave",
"credit": 7500,
"card": "NTU56789012",
"rfid": "1234abcd5678"
},
{
"name": "eve",
"credit": 3000,
"card": null,
"rfid": "deadbeef1234"
}
]
}

View File

@@ -1,20 +0,0 @@
{ lib
, python3Packages
, fetchFromGitHub
}:
python3Packages.buildPythonApplication {
pname = "dibbler";
version = "unstable-2021-09-07";
src = lib.cleanSource ../.;
format = "pyproject";
nativeBuildInputs = with python3Packages; [ setuptools ];
propagatedBuildInputs = with python3Packages; [
brother-ql
matplotlib
psycopg2
python-barcode
sqlalchemy
];
}

View File

@@ -1,84 +1,215 @@
{ config, pkgs, lib, ... }: let {
config,
pkgs,
lib,
...
}:
let
cfg = config.services.dibbler; cfg = config.services.dibbler;
in { worbleCfg = config.services.worblehat;
format = pkgs.formats.toml { };
in
{
options.services.dibbler = { options.services.dibbler = {
enable = lib.mkEnableOption "dibbler, the little kiosk computer";
package = lib.mkPackageOption pkgs "dibbler" { }; package = lib.mkPackageOption pkgs "dibbler" { };
config = lib.mkOption {
default = ../conf.py; 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 = { };
type = lib.types.submodule {
freeformType = format.type;
};
}; };
}; };
config = let config = lib.mkIf cfg.enable (
screen = "${pkgs.screen}/bin/screen"; lib.mkMerge [
in { {
boot = { services.dibbler.settings = lib.pipe ../example-config.toml [
consoleLogLevel = 0; builtins.readFile
enableContainers = false; builtins.fromTOML
loader.grub.enable = false; (lib.mapAttrsRecursive (_: lib.mkDefault))
}; ];
}
{
environment.systemPackages = [
cfg.package
worbleCfg.package
];
users = { environment.etc."dibbler/dibbler.toml".source = format.generate "dibbler.toml" cfg.settings;
groups.dibbler = { };
users.dibbler = {
group = "dibbler";
extraGroups = [ "lp" ];
isNormalUser = true;
shell = ((pkgs.writeShellScriptBin "login-shell" "${screen} -x dibbler") // {shellPath = "/bin/login-shell";});
};
};
systemd.services.screen-daemon = { users = {
description = "Dibbler service screen"; users.dibbler = {
wantedBy = [ "default.target" ]; group = "dibbler";
serviceConfig = { isNormalUser = true;
ExecStartPre = "-${screen} -X -S dibbler kill"; };
ExecStart = "${screen} -dmS dibbler -O -l ${cfg.package}/bin/dibbler --config ${cfg.config} loop"; groups.dibbler = { };
ExecStartPost = "${screen} -X -S dibbler width 42 80"; };
User = "dibbler";
Group = "dibbler";
Type = "forking";
RemainAfterExit = false;
Restart = "always";
RestartSec = "5s";
SuccessExitStatus = 1;
};
};
# https://github.com/NixOS/nixpkgs/issues/84105 services.dibbler.settings.database = lib.mkIf cfg.createLocalDatabase {
boot.kernelParams = [ type = "postgresql";
"console=ttyUSB0,9600" postgresql.host = "/run/postgresql";
"console=tty1" };
];
systemd.services."serial-getty@ttyUSB0" = {
enable = true;
wantedBy = [ "getty.target" ]; # to start at boot
serviceConfig.Restart = "always"; # restart when session is closed
};
services = { services.postgresql = lib.mkIf cfg.createLocalDatabase {
openssh = { ensureDatabases = [ "dibbler" ];
enable = true; ensureUsers = [
permitRootLogin = "yes"; {
}; name = "dibbler";
ensureDBOwnership = true;
ensureClauses.login = true;
}
];
};
getty.autologinUser = lib.mkForce "dibbler"; systemd.services.dibbler-setup-database = lib.mkIf cfg.createLocalDatabase {
udisks2.enable = false; 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.toml create-db";
ExecStartPost = "${lib.getExe' pkgs.coreutils "touch"} /var/lib/dibbler/.db-setup-done";
StateDirectory = "dibbler";
networking.firewall.logRefusedConnections = false; User = "dibbler";
console.keyMap = "no"; Group = "dibbler";
programs.command-not-found.enable = false; };
i18n.supportedLocales = [ "en_US.UTF-8/UTF-8" ]; };
environment.noXlibs = true; }
(lib.mkIf cfg.kioskMode {
boot.kernelParams = [
"console=tty1"
];
documentation = { users.users.dibbler = {
info.enable = false; extraGroups = [ "lp" ];
man.enable = false; shell =
}; (pkgs.writeShellScriptBin "login-shell" "${lib.getExe' cfg.screenPackage "screen"} -x dibbler")
// {
shellPath = "/bin/login-shell";
};
};
security = { services.dibbler.settings.general = {
polkit.enable = lib.mkForce false; quit_allowed = false;
audit.enable = false; stop_allowed = false;
}; };
};
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;
User = "dibbler";
Group = "dibbler";
ExecStartPre = "-${lib.getExe' cfg.screenPackage "screen"} -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"
# Set window name
"-t"
"dibblerino"
];
dibblerArgs = lib.cli.toCommandLineShellGNU { } {
config = "/etc/dibbler/dibbler.toml";
};
in
"${lib.getExe' cfg.screenPackage "screen"} ${screenArgs} ${lib.getExe cfg.package} ${dibblerArgs} loop";
ExecStartPost =
lib.optionals (cfg.limitScreenWidth != null) [
"${lib.getExe' cfg.screenPackage "screen"} -X -S dibbler width ${toString cfg.limitScreenWidth}"
]
++ lib.optionals (cfg.limitScreenHeight != null) [
"${lib.getExe' cfg.screenPackage "screen"} -X -S dibbler height ${toString cfg.limitScreenHeight}"
]
++ [
"${lib.getExe' cfg.screenPackage "screen"} -S dibbler -X screen -t worblehat ${lib.getExe worbleCfg.package}"
];
};
};
services.getty.autologinUser = "dibbler";
})
]
);
} }

View File

@@ -0,0 +1,54 @@
{ 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;
};
})
];
}

View File

@@ -0,0 +1,29 @@
{ 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;
};
})
];
}

49
nix/package.nix Normal file
View File

@@ -0,0 +1,49 @@
{ lib
, sourceInfo
, python3Packages
, makeWrapper
, less
}:
let
pyproject = builtins.fromTOML (builtins.readFile ../pyproject.toml);
in
python3Packages.buildPythonApplication {
pname = pyproject.project.name;
version = "0.1";
src = lib.cleanSource ../.;
format = "pyproject";
# brother-ql is breaky breaky
# https://github.com/NixOS/nixpkgs/issues/285234
# dontCheckRuntimeDeps = true;
env.SETUPTOOLS_SCM_PRETEND_METADATA = (x: "{${x}}") (lib.concatStringsSep ", " [
"node=\"${sourceInfo.rev or (lib.substring 0 64 sourceInfo.dirtyRev)}\""
"node_date=${lib.substring 0 4 sourceInfo.lastModifiedDate}-${lib.substring 4 2 sourceInfo.lastModifiedDate}-${lib.substring 6 2 sourceInfo.lastModifiedDate}"
"dirty=${if sourceInfo ? dirtyRev then "true" else "false"}"
]);
nativeBuildInputs = with python3Packages; [
makeWrapper
setuptools
setuptools-scm
];
propagatedBuildInputs = with python3Packages; [
# brother-ql
# matplotlib
psycopg2-binary
# python-barcode
sqlalchemy
];
postInstall = ''
wrapProgram $out/bin/dibbler \
--prefix PATH : "${lib.makeBinPath [ less ]}"
'';
meta = {
description = "The little kiosk that could";
mainProgram = "dibbler";
};
}

20
nix/shell.nix Normal file
View File

@@ -0,0 +1,20 @@
{
mkShell,
python,
ruff,
uv,
}:
mkShell {
packages = [
ruff
uv
(python.withPackages (ps: with ps; [
# brother-ql
# matplotlib
psycopg2
# python-barcode
sqlalchemy
]))
];
}

View File

@@ -1,31 +1,36 @@
[build-system] [build-system]
requires = ["setuptools", "setuptools-scm"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
requires = [
"setuptools",
"setuptools-scm",
]
[project] [project]
name = "dibbler" name = "dibbler"
authors = [] dynamic = ["version"]
authors = [
{ name = "Programvareverkstedet", email = "projects@pvv.ntnu.no" }
]
description = "EDB-system for PVV" description = "EDB-system for PVV"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = {text = "BSD-3-Clause"}
classifiers = [ classifiers = [
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
] ]
dependencies = [ dependencies = [
"SQLAlchemy >= 2.0, <2.1", "SQLAlchemy >= 2.0, <2.1",
"brother-ql", # "brother-ql",
"matplotlib", # "matplotlib",
"psycopg2 >= 2.8, <2.10", "psycopg2-binary >= 2.8, <2.10",
"python-barcode", # "python-barcode",
] ]
dynamic = ["version"] scripts.dibbler = "dibbler.main:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
include = ["dibbler*"] include = ["dibbler*"]
[project.scripts] [tool.setuptools_scm]
dibbler = "dibbler.main:main" version_file = "dibbler/_version.py"
[tool.black] [tool.black]
line-length = 100 line-length = 100
@@ -33,3 +38,34 @@ line-length = 100
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
[tool.ruff.lint]
select = [
"A", # flake8-builtins
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"COM", # flake8-commas
"ANN",
# "E", # pycodestyle
# "F", # Pyflakes
"FA", # flake8-future-annotations
"I", # isort
"S", # flake8-bandit
"ICN", # flake8-import-conventions
"ISC", # flake8-implicit-str-concat
# "N", # pep8-naming
"PTH", # flake8-use-pathlib
# "RET", # flake8-return
# "SIM", # flake8-simplify
"TC", # flake8-type-checking
"UP", # pyupgrade
"YTT", # flake8-2020
]
ignore = [
"E501", # line too long
"S101", # assert detected
"S311", # non-cryptographic random generator
]
[tool.ruff.lint.flake8-annotations]
suppress-dummy-args = true
ignore-fully-untyped = true

167
uv.lock generated Normal file
View File

@@ -0,0 +1,167 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "dibbler"
source = { editable = "." }
dependencies = [
{ name = "psycopg2-binary" },
{ name = "sqlalchemy" },
]
[package.metadata]
requires-dist = [
{ name = "psycopg2-binary", specifier = ">=2.8,<2.10" },
{ name = "sqlalchemy", specifier = ">=2.0,<2.1" },
]
[[package]]
name = "greenlet"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
{ url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
{ url = "https://files.pythonhosted.org/packages/1d/d5/c339b3b4bc8198b7caa4f2bd9fd685ac9f29795816d8db112da3d04175bb/greenlet-3.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:7652ee180d16d447a683c04e4c5f6441bae7ba7b17ffd9f6b3aff4605e9e6f71", size = 301164, upload-time = "2025-12-04T14:42:51.577Z" },
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
{ url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
{ url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" },
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
{ url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
{ url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" },
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
{ url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" },
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
{ url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ 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 = "psycopg2-binary"
version = "2.9.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/ae/8d8266f6dd183ab4d48b95b9674034e1b482a3f8619b33a0d86438694577/psycopg2_binary-2.9.11-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0e8480afd62362d0a6a27dd09e4ca2def6fa50ed3a4e7c09165266106b2ffa10", size = 3756452, upload-time = "2025-10-10T11:11:11.583Z" },
{ url = "https://files.pythonhosted.org/packages/4b/34/aa03d327739c1be70e09d01182619aca8ebab5970cd0cfa50dd8b9cec2ac/psycopg2_binary-2.9.11-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:763c93ef1df3da6d1a90f86ea7f3f806dc06b21c198fa87c3c25504abec9404a", size = 3863957, upload-time = "2025-10-10T11:11:16.932Z" },
{ url = "https://files.pythonhosted.org/packages/48/89/3fdb5902bdab8868bbedc1c6e6023a4e08112ceac5db97fc2012060e0c9a/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e164359396576a3cc701ba8af4751ae68a07235d7a380c631184a611220d9a4", size = 4410955, upload-time = "2025-10-10T11:11:21.21Z" },
{ url = "https://files.pythonhosted.org/packages/ce/24/e18339c407a13c72b336e0d9013fbbbde77b6fd13e853979019a1269519c/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d57c9c387660b8893093459738b6abddbb30a7eab058b77b0d0d1c7d521ddfd7", size = 4468007, upload-time = "2025-10-10T11:11:24.831Z" },
{ url = "https://files.pythonhosted.org/packages/91/7e/b8441e831a0f16c159b5381698f9f7f7ed54b77d57bc9c5f99144cc78232/psycopg2_binary-2.9.11-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2c226ef95eb2250974bf6fa7a842082b31f68385c4f3268370e3f3870e7859ee", size = 4165012, upload-time = "2025-10-10T11:11:29.51Z" },
{ url = "https://files.pythonhosted.org/packages/0d/61/4aa89eeb6d751f05178a13da95516c036e27468c5d4d2509bb1e15341c81/psycopg2_binary-2.9.11-cp311-cp311-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a311f1edc9967723d3511ea7d2708e2c3592e3405677bf53d5c7246753591fbb", size = 3981881, upload-time = "2025-10-30T02:55:07.332Z" },
{ url = "https://files.pythonhosted.org/packages/76/a1/2f5841cae4c635a9459fe7aca8ed771336e9383b6429e05c01267b0774cf/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ebb415404821b6d1c47353ebe9c8645967a5235e6d88f914147e7fd411419e6f", size = 3650985, upload-time = "2025-10-10T11:11:34.975Z" },
{ url = "https://files.pythonhosted.org/packages/84/74/4defcac9d002bca5709951b975173c8c2fa968e1a95dc713f61b3a8d3b6a/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f07c9c4a5093258a03b28fab9b4f151aa376989e7f35f855088234e656ee6a94", size = 3296039, upload-time = "2025-10-10T11:11:40.432Z" },
{ url = "https://files.pythonhosted.org/packages/6d/c2/782a3c64403d8ce35b5c50e1b684412cf94f171dc18111be8c976abd2de1/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:00ce1830d971f43b667abe4a56e42c1e2d594b32da4802e44a73bacacb25535f", size = 3043477, upload-time = "2025-10-30T02:55:11.182Z" },
{ url = "https://files.pythonhosted.org/packages/c8/31/36a1d8e702aa35c38fc117c2b8be3f182613faa25d794b8aeaab948d4c03/psycopg2_binary-2.9.11-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cffe9d7697ae7456649617e8bb8d7a45afb71cd13f7ab22af3e5c61f04840908", size = 3345842, upload-time = "2025-10-10T11:11:45.366Z" },
{ url = "https://files.pythonhosted.org/packages/6e/b4/a5375cda5b54cb95ee9b836930fea30ae5a8f14aa97da7821722323d979b/psycopg2_binary-2.9.11-cp311-cp311-win_amd64.whl", hash = "sha256:304fd7b7f97eef30e91b8f7e720b3db75fee010b520e434ea35ed1ff22501d03", size = 2713894, upload-time = "2025-10-10T11:11:48.775Z" },
{ url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" },
{ url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" },
{ url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" },
{ url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" },
{ url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" },
{ url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" },
{ url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" },
{ url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" },
{ url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" },
{ url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" },
{ url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" },
{ url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" },
{ url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" },
{ url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" },
{ url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" },
{ url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" },
{ url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" },
{ url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" },
{ url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" },
{ url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" },
{ url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" },
{ url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" },
{ url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" },
{ url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" },
{ url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" },
{ url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" },
{ 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 = "sqlalchemy"
version = "2.0.45"
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" }
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" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
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" },
]