Compare commits

...

25 Commits

Author SHA1 Message Date
01e926b140 Fix display name on yunohost installation 2026-01-23 10:54:38 +01:00
ab372f5d0c Add alembic dependency 2026-01-23 10:31:02 +01:00
de2d7c12cd Add Readme 2026-01-23 10:30:30 +01:00
dd996a78ca Add Makefile 2026-01-23 10:20:55 +01:00
cb95d6053d Initialize alembic 2026-01-23 10:15:38 +01:00
977dd30e5c Translate some text fragments into German 2026-01-23 10:00:35 +01:00
45bd37e26c Refactor user admin with Form() dependency 2026-01-17 12:35:31 +01:00
a451d2e532 Add product admin 2026-01-17 12:29:47 +01:00
a6e97c6170 feat(admin): Add group admin and test for admin views 2026-01-03 10:39:52 +01:00
927b4b0b4e feat(users): Add and remove user groups 2025-12-30 17:03:52 +01:00
bafca3c291 Start User management 2025-12-13 15:15:18 +01:00
a5ac52a387 Highlight current nav item 2025-12-13 14:42:05 +01:00
d1fc874c35 Modify cart items 2025-12-13 14:30:14 +01:00
5d2dfe37c1 View order 2025-12-13 12:25:00 +01:00
fd544fcebc feat(order): Add finalize order functionality 2025-12-13 11:53:41 +01:00
00246819cc Implement shopping cart 2025-12-05 11:39:51 +01:00
f4618f4d05 Add shopping cart and related models 2025-11-11 12:08:06 +01:00
b3166811e5 Started with area page 2025-10-29 10:31:09 +01:00
bd2f7b286e Add content to shop page 2025-10-29 09:39:42 +01:00
8fd8b710fb New models and first data on the landing page 2025-10-28 21:06:49 +01:00
81929cca21 Add first info to landing page 2025-10-23 12:35:52 +02:00
f6e69b1521 Add justfile
Only target so far is lint
2025-10-23 11:41:57 +02:00
e1c8b4ebeb Add authorization 2025-10-23 11:41:52 +02:00
a1563b53ac Add project setup some models and a test 2025-10-22 12:01:43 +02:00
d4fed48074 Framework setup 2025-10-18 00:26:57 +02:00
47 changed files with 3524 additions and 81 deletions

92
.gitignore vendored Normal file
View File

@@ -0,0 +1,92 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
python-env/
.uv/
.uvcache/
# IDE-specific files
.idea/
.vscode/
*.swp
*.swo
# Database files
*.db
*.sqlite3
*.sqlite3-journal
# Logs and media
*.log
*.pot
*.log.*
logs/
media/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Python specific
instance/
.webassets-cache
*.sublime-workspace
*.sublime-project
# Local override files
*.local.yml
*.override.yml
*.local.env
*.override.env
# uv specific
.uvcache/
/db_fixtures/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

81
Ideensammlung.md Normal file
View File

@@ -0,0 +1,81 @@
# Allmende Bezahlsystem
## Grundlegende Ideen
Das Allmende Bezahlsystem ermöglicht es Bewohner*innen der Allmende Waren und Leistungen in der Allmende bequem und einfach zu bezahlen.
Dazu gehören zum Beispiel:
* Das gemeinsame Essen
* Waren aus der Food-Coop
* Waschen und Trocknen
* Kaffee und Getränke im großen Gemeinschaftsraum
Das System ist leicht um weitere Waren und Leitungen zu erweitern.
#### Schnittstellen
Das System ist so gebaut, dass Schnittstellen zu anderen Systemen leicht gebaut werden können, anstatt deren Funktionalität zu integrieren. Beispiele:
* Essensanmeldung: Angemeldete Essen können automatisch eingelesen und bezahlt werden.
* Food-Coop-Waage: Das noch zu erstellende Waagensystem kann die Produkte für die Food-Coop auslesen, dem User zur Auswahl stellen und dann eine Transaktion mit der gekauften Menge ablegen.
* Warenwirtschaftssystem: Gekaufte Mengen in der Food-Coop können automatisch in ein Warenwirtschaftssystem der Food-Coop ausgeleitet werden.
* Buchhaltung: Alle für die Buchhaltung relevanten Daten können manuell oder automatisch exportiert werden.
* ...
### User
Das System ist über LDAP an die bestehende Cloud der Allmende angebunden, so können alle einfach ihren gewohnten Zugang verwenden.
#### Nutzer*innen-gruppen
Es wird folgende Nutzer*innen-gruppen geben:
* Normale Nutzer*innen: Können Transaktionen für ihre eigenen Konten eintragen und sehen
* Bereichsadmins: Können Produkte für ihnen zugeordnete Bereiche bearbeiten.
* Admins: Können alle Transaktionen sehen und exportieren (Buchhaltung)
### Konten
Guthaben im System werden auf **Konten** verwaltet. Durch Überweisung auf ein Konto des Hausvereins Allmende e.V. kann Guthaben aufgeladen werden. Da viele Haushalte ein gemeinsames Konto führen werden, aber einzelne User-Zugänge haben, können mehrere User zu einem Konto hinzugefügt werden.
Ein User kann auch auf mehrere Konten Zugriff haben, da es auch Gruppenkonten (z.B. AG Kochen) geben wird.
Konten werden von Admins angelegt und verwaltet.
Für jedes Konto kann jederzeit der **Kontoauszug** angesehen werden, eine vollständige Auflistung aller Transaktionen filterbar nach Zeitraum.
### Produkte
Alles was man im Bezahlsystem bezahlen kann ist ein **Produkt**. Produkte haben folgende Eigenschaften:
* Name
* Netto-Preis
* MwSt-Satz
* Abrechnungsgröße (Stück, kg, etc)
* Beschreibung
* ...
### Bereiche
Produkte können in Bereiche sortiert werden (Food-Coop, Waschküche, etc), so dass sie leichter gefunden werden können.
### Bezahlvorgang
Der standard Bezahlvorgang läuft wie folgt ab:
* User loggt sich ein
* Wählt Produkt aus, stellt Menge ein
* Drückt auf Bezahlen
Transaktionen bleiben für 24 Stunden für den User bearbeit- und löschbar, um Fehler selbst zu korrigieren. Danach ist dies nur noch durch Admins möglich.
#### PoS (Point of Sales) - Setup
Für maximalen Komfort sollte es am Ort des Einkaufs (Waschküche, Food-Coop, Kaffeemaschine) möglich sein, direkt die Bezahlung durchzuführen. Dafür ist es denkbar dort Tablets aufzuhängen über man schnell sein Konto auswählt und den Einkauf aus einem vorausgewählten Sortiment (etc. in der Waschküche nur Waschen und Trocknen).
Fraglich ist hier, welche Art der Authentifizierung man hier wählt. Jedes Mal Nutzer und Passwort eingeben zu müssen kann eine zu hohe Hürde sein. Denkbar wäre es, diese Transaktionen durch eine kürzere PIN zu autorisieren oder sogar ganz auf das Vertrauenssystem (keine Authentifizierung am PoS) zu setzen.
Es ist natürlich auch möglich, die Papierlisten erstmal beizubehalten und die Striche dann von Admins ins System übertragen zu lassen. So kann man das System schrittweise einführen.

7
Makefile Normal file
View File

@@ -0,0 +1,7 @@
lint:
uv run isort test src
uv run black test src
reset_db:
rm -f aps_db.db
uv run alembic upgrade heads

View File

@@ -1,81 +0,0 @@
# Allmende Bezahlsystem
## Grundlegende Ideen
Das Allmende Bezahlsystem ermöglicht es Bewohner*innen der Allmende Waren und Leistungen in der Allmende bequem und einfach zu bezahlen.
Dazu gehören zum Beispiel:
* Das gemeinsame Essen
* Waren aus der Food-Coop
* Waschen und Trocknen
* Kaffee und Getränke im großen Gemeinschaftsraum
Das System ist leicht um weitere Waren und Leitungen zu erweitern.
#### Schnittstellen
Das System ist so gebaut, dass Schnittstellen zu anderen Systemen leicht gebaut werden können, anstatt deren Funktionalität zu integrieren. Beispiele:
* Essensanmeldung: Angemeldete Essen können automatisch eingelesen und bezahlt werden.
* Food-Coop-Waage: Das noch zu erstellende Waagensystem kann die Produkte für die Food-Coop auslesen, dem User zur Auswahl stellen und dann eine Transaktion mit der gekauften Menge ablegen.
* Warenwirtschaftssystem: Gekaufte Mengen in der Food-Coop können automatisch in ein Warenwirtschaftssystem der Food-Coop ausgeleitet werden.
* Buchhaltung: Alle für die Buchhaltung relevanten Daten können manuell oder automatisch exportiert werden.
* ...
### User
Das System ist über LDAP an die bestehende Cloud der Allmende angebunden, so können alle einfach ihren gewohnten Zugang verwenden.
#### Nutzer*innen-gruppen
Es wird folgende Nutzer*innen-gruppen geben:
* Normale Nutzer*innen: Können Transaktionen für ihre eigenen Konten eintragen und sehen
* Bereichsadmins: Können Produkte für ihnen zugeordnete Bereiche bearbeiten.
* Admins: Können alle Transaktionen sehen und exportieren (Buchhaltung)
### Konten
Guthaben im System werden auf **Konten** verwaltet. Durch Überweisung auf ein Konto des Hausvereins Allmende e.V. kann Guthaben aufgeladen werden. Da viele Haushalte ein gemeinsames Konto führen werden, aber einzelne User-Zugänge haben, können mehrere User zu einem Konto hinzugefügt werden.
Ein User kann auch auf mehrere Konten Zugriff haben, da es auch Gruppenkonten (z.B. AG Kochen) geben wird.
Konten werden von Admins angelegt und verwaltet.
Für jedes Konto kann jederzeit der **Kontoauszug** angesehen werden, eine vollständige Auflistung aller Transaktionen filterbar nach Zeitraum.
### Produkte
Alles was man im Bezahlsystem bezahlen kann ist ein **Produkt**. Produkte haben folgende Eigenschaften:
* Name
* Netto-Preis
* MwSt-Satz
* Abrechnungsgröße (Stück, kg, etc)
* Beschreibung
* ...
### Bereiche
Produkte können in Bereiche sortiert werden (Food-Coop, Waschküche, etc), so dass sie leichter gefunden werden können.
### Bezahlvorgang
Der standard Bezahlvorgang läuft wie folgt ab:
* User loggt sich ein
* Wählt Produkt aus, stellt Menge ein
* Drückt auf Bezahlen
Transaktionen bleiben für 24 Stunden für den User bearbeit- und löschbar, um Fehler selbst zu korrigieren. Danach ist dies nur noch durch Admins möglich.
#### PoS (Point of Sales) - Setup
Für maximalen Komfort sollte es am Ort des Einkaufs (Waschküche, Food-Coop, Kaffeemaschine) möglich sein, direkt die Bezahlung durchzuführen. Dafür ist es denkbar dort Tablets aufzuhängen über man schnell sein Konto auswählt und den Einkauf aus einem vorausgewählten Sortiment (etc. in der Waschküche nur Waschen und Trocknen).
Fraglich ist hier, welche Art der Authentifizierung man hier wählt. Jedes Mal Nutzer und Passwort eingeben zu müssen kann eine zu hohe Hürde sein. Denkbar wäre es, diese Transaktionen durch eine kürzere PIN zu autorisieren oder sogar ganz auf das Vertrauenssystem (keine Authentifizierung am PoS) zu setzen.
Es ist natürlich auch möglich, die Papierlisten erstmal beizubehalten und die Striche dann von Admins ins System übertragen zu lassen. So kann man das System schrittweise einführen.

147
alembic.ini Normal file
View File

@@ -0,0 +1,147 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = sqlite:///aps_db.db
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

80
alembic/env.py Normal file
View File

@@ -0,0 +1,80 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
from allmende_payment_system.models import Base
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

28
alembic/script.py.mako Normal file
View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,132 @@
"""Initial model
Revision ID: 958d7aee2b21
Revises:
Create Date: 2026-01-23 10:10:36.825333
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '958d7aee2b21'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('aps_account',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('aps_area',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('image_path', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('aps_user',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
)
op.create_table('aps_user_group',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('aps_order',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('aps_permission',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scope', sa.String(), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('user_group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_group_id'], ['aps_user_group.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('aps_product',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('unit_of_measure', sa.Enum('g', 'kg', 'l', 'piece', native_enum=False), nullable=False),
sa.Column('allow_fractional', sa.Boolean(), nullable=False),
sa.Column('vat_rate', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('area_id', sa.Integer(), nullable=False),
sa.Column('image_path', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['area_id'], ['aps_area.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
)
op.create_table('aps_user_account_association',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['aps_account.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'account_id')
)
op.create_table('aps_user_user_group_association',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('user_group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_group_id'], ['aps_user_group.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'user_group_id')
)
op.create_table('aps_order_item',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['aps_order.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['aps_product.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('aps_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('type', sa.Enum('order', 'deposit', 'withdrawal', 'expense', native_enum=False), nullable=False),
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=True),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['aps_account.id'], ),
sa.ForeignKeyConstraint(['order_id'], ['aps_order.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('aps_transaction')
op.drop_table('aps_order_item')
op.drop_table('aps_user_user_group_association')
op.drop_table('aps_user_account_association')
op.drop_table('aps_product')
op.drop_table('aps_permission')
op.drop_table('aps_order')
op.drop_table('aps_user_group')
op.drop_table('aps_user')
op.drop_table('aps_area')
op.drop_table('aps_account')
# ### end Alembic commands ###

7
dev-server.sh Executable file
View File

@@ -0,0 +1,7 @@
if [ -z "${APS_username}" ]; then
export APS_username="testuser"
fi
if [ -z "${APS_display_name}" ]; then
export APS_display_name="Dr. T. Estuser"
fi
fastapi dev src/allmende_payment_system/app.py

8
justfile Normal file
View File

@@ -0,0 +1,8 @@
lint:
uv run isort test src
uv run black test src
reset_db:
rm -f aps_db.db
uv run alembic upgrade heads
for file in db_fixtures/*.sql; do sqlite3 aps_db.db < "$file"; done

33
pyproject.toml Normal file
View File

@@ -0,0 +1,33 @@
[project]
name = "allmende-payment-system"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Niklas Meinzer", email = "mail@niklas-meinzer.de" }
]
requires-python = ">=3.13,<3.14"
dependencies = [
"alembic>=1.18.1",
"fastapi[standard]>=0.119.0",
"sqlalchemy>=2.0.44",
]
[project.scripts]
allmende-payment-system = "allmende_payment_system:main"
[build-system]
requires = ["uv_build>=0.9.2,<0.10.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"black>=25.9.0",
"faker>=38.2.0",
"httpx>=0.28.1",
"isort>=7.0.0",
"pytest>=8.4.2",
]
[tool.isort]
profile = "black"

View File

View File

@@ -0,0 +1,20 @@
from fastapi import APIRouter, Request
from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.tools import get_jinja_renderer
root_router = APIRouter()
templates = get_jinja_renderer()
@root_router.get("/")
async def landing_page(request: Request, user: UserDep, session: SessionDep):
transactions = []
for account in user.accounts:
transactions += account.transactions
transactions = sorted(transactions, key=lambda t: t.timestamp)
return templates.TemplateResponse(
"index.html.jinja",
context={"request": request, "user": user, "transactions": transactions},
)

View File

@@ -0,0 +1,278 @@
from decimal import Decimal
from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Request
from sqlalchemy import select
from starlette import status
from starlette.responses import RedirectResponse
from allmende_payment_system import types
from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.models import Area, Permission, Product, User, UserGroup
from allmende_payment_system.tools import get_jinja_renderer
admin_router = APIRouter(prefix="/admin")
# USERS
@admin_router.get("/users")
async def user_list(request: Request, session: SessionDep, user: UserDep):
if not user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
users = session.scalars(select(User)).all()
groups = session.scalars(select(UserGroup)).all()
templates = get_jinja_renderer()
return templates.TemplateResponse(
"users.html.jinja",
context={"request": request, "users": users, "all_groups": groups},
)
@admin_router.post("/users/{user_id}/add_group")
async def user_add_group(
request: Request,
session: SessionDep,
loggend_in_user: UserDep,
user_id: int,
group_id: Annotated[int, Form()],
):
if not loggend_in_user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
group = session.execute(
select(UserGroup).where(UserGroup.id == group_id)
).scalar_one()
user = session.execute(select(User).where(User.id == user_id)).scalar_one()
user.user_groups.append(group)
return RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
@admin_router.get("/users/{user_id}/remove_group/{group_id}")
async def user_remove_group(
request: Request,
session: SessionDep,
loggend_in_user: UserDep,
user_id: int,
group_id: int,
):
if not loggend_in_user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
group = session.execute(
select(UserGroup).where(UserGroup.id == group_id)
).scalar_one()
user = session.execute(select(User).where(User.id == user_id)).scalar_one()
print(user)
user.user_groups.remove(group)
return RedirectResponse(url="/admin/users", status_code=status.HTTP_303_SEE_OTHER)
# GROUPS
@admin_router.get("/groups")
async def group_list(request: Request, session: SessionDep, user: UserDep):
if not user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
groups = session.scalars(select(UserGroup)).all()
templates = get_jinja_renderer()
return templates.TemplateResponse(
"groups.html.jinja",
context={"request": request, "groups": groups},
)
@admin_router.post("/groups/{group_id}/add_permission")
async def group_add_permission(
request: Request,
session: SessionDep,
user: UserDep,
group_id: int,
permission: Annotated[str, Form()],
):
if not user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
scope_action = permission.split(":")
if len(scope_action) != 2:
raise HTTPException(
status_code=400, detail="Permission must be in the format 'scope:action'"
)
permission = Permission(scope=scope_action[0], action=scope_action[1])
group = session.execute(
select(UserGroup).where(UserGroup.id == group_id)
).scalar_one()
session.add(permission)
group.permissions.append(permission)
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
@admin_router.get("/groups/{group_id}/remove_permission/{permission_id}")
async def group_remove_permission(
request: Request,
session: SessionDep,
user: UserDep,
group_id: int,
permission_id: int,
):
if not user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
permission = session.execute(
select(Permission).where(Permission.id == permission_id)
).scalar_one()
group = session.execute(
select(UserGroup).where(UserGroup.id == group_id)
).scalar_one()
group.permissions.remove(permission)
session.delete(permission)
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
@admin_router.post("/groups/create")
async def create_group(
request: Request,
session: SessionDep,
user: UserDep,
group_data: Annotated[types.UserGroup, Form()],
):
if not user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
group = UserGroup(name=group_data.name, description=group_data.description)
session.add(group)
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
@admin_router.get("/groups/{group_id}/delete")
async def delete_group(
request: Request,
session: SessionDep,
user: UserDep,
group_id: int,
):
if not user.has_permission("user", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
group = session.execute(
select(UserGroup).where(UserGroup.id == group_id)
).scalar_one()
session.delete(group)
return RedirectResponse(url="/admin/groups", status_code=status.HTTP_303_SEE_OTHER)
# PRODUCTS
@admin_router.get("/products")
async def get_products(
request: Request,
session: SessionDep,
user: UserDep,
):
products = session.scalars(
select(Product).order_by(Product.area_id, Product.name)
).all()
templates = get_jinja_renderer()
return templates.TemplateResponse(
"products.html.jinja",
context={"request": request, "products": products},
)
@admin_router.get("/products/edit/{product_id}")
async def edit_product_get(
request: Request, session: SessionDep, user: UserDep, product_id: int
):
product = session.execute(select(Product).where(Product.id == product_id)).scalar()
areas = session.scalars(select(Area)).all()
templates = get_jinja_renderer()
return templates.TemplateResponse(
"product_edit.html.jinja",
context={
"request": request,
"product": product,
"edit_mode": True,
"areas": areas,
},
)
@admin_router.post("/products/edit/{product_id}")
async def edit_product_post(
request: Request,
session: SessionDep,
user: UserDep,
product_id: int,
product_data: Annotated[types.Product, Form()],
):
if not user.has_permission("product", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
product = session.execute(
select(Product).where(Product.id == product_id)
).scalar_one()
for field_name, data in product_data.model_dump().items():
setattr(product, field_name, data)
session.flush()
return RedirectResponse(
url="/admin/products", status_code=status.HTTP_303_SEE_OTHER
)
@admin_router.get("/products/new")
async def new_product_get(
request: Request,
session: SessionDep,
user: UserDep,
):
if not user.has_permission("product", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
areas = session.scalars(select(Area)).all()
templates = get_jinja_renderer()
return templates.TemplateResponse(
"product_edit.html.jinja",
context={"request": request, "edit_mode": False, "areas": areas},
)
@admin_router.post("/products/new")
async def new_product_post(
request: Request,
session: SessionDep,
user: UserDep,
product_data: Annotated[types.Product, Form()],
# product_image: Annotated[bytes, File()]
):
if not user.has_permission("product", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
# print(len(product_image))
product = Product()
for field_name, data in product_data.model_dump().items():
setattr(product, field_name, data)
session.add(product)
return RedirectResponse(
url="/admin/products", status_code=status.HTTP_303_SEE_OTHER
)

View File

@@ -0,0 +1,44 @@
import os
from typing import Annotated
from fastapi import Depends, HTTPException, Request
from sqlalchemy.orm import Session
from allmende_payment_system.database import SessionLocal, ensure_user
from allmende_payment_system.models import User
def get_session() -> Session:
db = SessionLocal()
try:
yield db
finally:
db.commit()
db.close()
SessionDep = Annotated[Session, Depends(get_session)]
async def get_user(request: Request) -> dict:
if username := os.environ.get("APS_username", None):
return {
"username": username,
"display_name": os.environ.get("APS_display_name", "Missing Display Name"),
}
if "ynh_user" not in request.headers:
raise HTTPException(status_code=401, detail="Missing ynh_user header")
return {"username": request.headers["ynh_user"], "display_name": request.headers["ynh_user_fullname"]}
async def get_user_object(request: Request, session: SessionDep) -> User:
user_info = await get_user(request)
user = ensure_user(user_info, session)
request.state.user = user
return user
UserDep = Annotated[User, Depends(get_user_object)]

View File

@@ -0,0 +1,132 @@
from decimal import Decimal
from fastapi import APIRouter, HTTPException, Request
from sqlalchemy import select
from starlette import status
from starlette.responses import RedirectResponse
from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.models import Area, Order, OrderItem, Product
from allmende_payment_system.tools import get_jinja_renderer
shop_router = APIRouter()
templates = get_jinja_renderer()
@shop_router.get("/shop")
async def get_shop(request: Request, session: SessionDep):
query = select(Area)
areas = session.scalars(query).all()
return templates.TemplateResponse(
"shop.html.jinja",
context={"request": request, "areas": areas},
)
@shop_router.get("/shop/cart")
async def get_cart(request: Request, session: SessionDep, user: UserDep):
return templates.TemplateResponse(
"order.html.jinja",
context={"request": request, "order": user.shopping_cart, "is_cart": True},
)
@shop_router.get("/shop/finalize_order")
async def finalize_order(request: Request, session: SessionDep, user: UserDep):
cart = user.shopping_cart
# TODO: Implement
cart.finalize(user.accounts[0])
return RedirectResponse(url=f"/", status_code=status.HTTP_302_FOUND)
@shop_router.get("/shop/area/{area_id}")
async def get_shop_area(request: Request, session: SessionDep, area_id: int):
query = select(Area).where(Area.id == area_id)
area = session.scalars(query).one()
return templates.TemplateResponse(
"area.html.jinja",
context={"request": request, "area": area},
)
@shop_router.post("/shop/cart/add")
async def add_to_cart(request: Request, session: SessionDep, user: UserDep):
form_data = await request.form()
query = select(Product).where(Product.id == form_data["product_id"])
product = session.scalars(query).one()
quantity = Decimal(form_data["quantity"])
total_amount = product.price * quantity
order_item = OrderItem(
product=product, quantity=quantity, total_amount=total_amount
)
session.add(order_item)
cart = user.shopping_cart
cart.items.append(order_item)
session.flush()
return RedirectResponse(
url=f"/shop/area/{form_data['area_id']}", status_code=status.HTTP_302_FOUND
)
@shop_router.get("/shop/cart/remove/{item_id}")
async def remove_from_cart(
request: Request, session: SessionDep, user: UserDep, item_id: int
):
cart = user.shopping_cart
for item in cart.items:
if item.id == item_id:
item.order = None
session.delete(item)
break
else:
raise HTTPException(status_code=404, detail="Item not found in cart.")
return RedirectResponse(url=f"/shop/cart", status_code=status.HTTP_302_FOUND)
@shop_router.post("/shop/cart/update/{item_id}")
async def update_cart_item(
request: Request, session: SessionDep, user: UserDep, item_id: int
):
form_data = await request.form()
cart = user.shopping_cart
for item in cart.items:
if item.id == item_id:
item.update_quantity(Decimal(form_data["quantity"]))
break
else:
raise HTTPException(status_code=404, detail="Item not found in cart.")
return RedirectResponse(url=f"/shop/cart", status_code=status.HTTP_302_FOUND)
@shop_router.get("/shop/order/{order_id}")
async def get_order_details(
request: Request, session: SessionDep, user: UserDep, order_id: int
):
query = select(Order).where(Order.id == order_id)
order = session.scalars(query).one()
if user.id != order.user_id:
raise HTTPException(
status_code=403, detail=f"User not authorized to view this order."
)
return templates.TemplateResponse(
"order.html.jinja",
context={"request": request, "order": order, "is_cart": False},
)

View File

@@ -0,0 +1,24 @@
import locale
from fastapi import Depends, FastAPI
from fastapi.staticfiles import StaticFiles
from allmende_payment_system.api import root_router
from allmende_payment_system.api.admin import admin_router
from allmende_payment_system.api.dependencies import get_user_object
from allmende_payment_system.api.shop import shop_router
locale.setlocale(locale.LC_ALL, "de_DE.UTF-8")
app = FastAPI(dependencies=[Depends(get_user_object)])
app.mount(
"/static",
StaticFiles(directory="src/allmende_payment_system/static"),
name="static",
)
app.include_router(root_router)
app.include_router(shop_router)
app.include_router(admin_router)

View File

@@ -0,0 +1,41 @@
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from allmende_payment_system.models import User
SQLALCHEMY_DATABASE_URL = "sqlite:///./aps_db.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def create_tables():
from allmende_payment_system.models import Base
Base.metadata.create_all(bind=engine)
def ensure_user(user_info: dict, session: Session) -> User:
"""
Retrieve an existing user or create a new one if it doesn't exist.
This function queries the database for a user with the given username.
If found, it returns the existing user. If not found, it creates a new user
with the provided information, adds it to the session, and returns it.
:param user_info: Dictionary containing user information with keys:
- "username" (str): The unique username to search for or create
- "display_name" (str, optional): The display name for the new user
:param session: SQLAlchemy session for database operations
:return: The existing or newly created user object
"""
statement = select(User).where(User.username == user_info["username"])
if user := session.scalars(statement).one_or_none():
return user
user = User(
username=user_info["username"], display_name=user_info.get("display_name")
)
session.add(user)
session.flush()
return user

View File

@@ -0,0 +1,247 @@
import datetime
import decimal
import typing
from sqlalchemy import Column, ForeignKey, Numeric, Table, select
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import (
DeclarativeBase,
Mapped,
mapped_column,
object_session,
relationship,
)
from allmende_payment_system.types import UnitsOfMeasure
TABLE_PREFIX = "aps_"
class Base(DeclarativeBase):
pass
class Account(Base):
__tablename__ = TABLE_PREFIX + "account"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
users: Mapped[list["User"]] = relationship(
"User",
secondary=TABLE_PREFIX + "user_account_association",
back_populates="accounts",
)
transactions: Mapped[list["Transaction"]] = relationship("Transaction")
@property
def balance(self):
return sum(t.total_amount for t in self.transactions)
user_account_association = Table(
TABLE_PREFIX + "user_account_association",
Base.metadata,
Column("user_id", ForeignKey(TABLE_PREFIX + "user.id"), primary_key=True),
Column("account_id", ForeignKey(TABLE_PREFIX + "account.id"), primary_key=True),
)
class User(Base):
__tablename__ = TABLE_PREFIX + "user"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(nullable=False, unique=True)
display_name: Mapped[str] = mapped_column(nullable=False)
accounts: Mapped[list["Account"]] = relationship(
"Account", secondary=user_account_association, back_populates="users"
)
orders: Mapped[list["Order"]] = relationship("Order", back_populates="user")
user_groups: Mapped[list["UserGroup"]] = relationship(
"UserGroup",
secondary=TABLE_PREFIX + "user_user_group_association",
back_populates="users",
)
@property
def shopping_cart(self):
for order in self.orders:
if order.transaction is None:
cart = order
break
else:
cart = Order(user=self)
session = object_session(self)
session.add(cart)
return cart
def has_permission(self, scope: str, action: str) -> bool:
for group in self.user_groups:
for permission in group.permissions:
if permission.scope == scope and permission.action == action:
return True
return False
class UserGroup(Base):
__tablename__ = TABLE_PREFIX + "user_group"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
description: Mapped[str] = mapped_column(nullable=True)
permissions = relationship("Permission", back_populates="user_group")
users: Mapped[list["User"]] = relationship(
"User",
secondary=TABLE_PREFIX + "user_user_group_association",
back_populates="user_groups",
)
user_group_association = Table(
TABLE_PREFIX + "user_user_group_association",
Base.metadata,
Column("user_id", ForeignKey(TABLE_PREFIX + "user.id"), primary_key=True),
Column(
"user_group_id", ForeignKey(TABLE_PREFIX + "user_group.id"), primary_key=True
),
)
class Permission(Base):
__tablename__ = TABLE_PREFIX + "permission"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
scope: Mapped[str] = mapped_column(nullable=False)
action: Mapped[str] = mapped_column(nullable=False)
user_group_id: Mapped[int] = mapped_column(
ForeignKey(TABLE_PREFIX + "user_group.id")
)
user_group: Mapped["UserGroup"] = relationship(
"UserGroup", back_populates="permissions"
)
class Area(Base):
__tablename__ = TABLE_PREFIX + "area"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
description: Mapped[str] = mapped_column(nullable=True)
image_path: Mapped[str] = mapped_column(nullable=True)
products: Mapped[list["Product"]] = relationship("Product")
class Product(Base):
__tablename__ = TABLE_PREFIX + "product"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False)
allow_fractional: Mapped[bool] = mapped_column(nullable=False, default=True)
# TODO: limit this to actually used vat rates?
vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
area_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "area.id"))
area: Mapped["Area"] = relationship("Area", back_populates="products")
image_path: Mapped[str] = mapped_column(nullable=True)
class Order(Base):
__tablename__ = TABLE_PREFIX + "order"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "user.id"))
user: Mapped[User] = relationship("User", back_populates="orders")
transaction: Mapped["Transaction | None"] = relationship("Transaction")
items: Mapped[list["OrderItem"]] = relationship(
"OrderItem", cascade="all, delete-orphan", back_populates="order"
)
@property
def is_in_shopping_cart(self):
return self.transaction is None
@property
def total_amount(self):
return sum(item.total_amount for item in self.items)
def finalize(self, account: Account):
"""
Moves the order from the shopping cart to a given account
and adds a transaction to the account.
:param account: The account to which the order should be finalized
:raises ValueError: If the order is already finalized or empty"""
if not self.is_in_shopping_cart:
raise ValueError("Order is already finalized.")
if not self.items:
raise ValueError("Cannot finalize an empty order.")
assert account in self.user.accounts, "Account does not belong to user."
# create a transaction for the order
transaction = Transaction(
type="order",
total_amount=-self.total_amount,
order=self,
account=account,
)
session = object_session(self)
session.add(transaction)
class OrderItem(Base):
__tablename__ = TABLE_PREFIX + "order_item"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
order_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "order.id"))
order: Mapped[Order] = relationship("Order", back_populates="items")
product_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "product.id"))
product: Mapped[Product] = relationship("Product")
quantity: Mapped[decimal.Decimal] = mapped_column(nullable=False)
total_amount: Mapped[decimal.Decimal] = mapped_column(
Numeric(10, 2), nullable=False
)
def update_quantity(self, new_quantity: decimal.Decimal):
if new_quantity <= 0:
raise ValueError("Quantity must be positive.")
self.quantity = new_quantity
self.total_amount = self.product.price * new_quantity
TransactionTypes = typing.Literal[
"order",
"deposit",
"withdrawal",
"expense",
]
class Transaction(Base):
__tablename__ = TABLE_PREFIX + "transaction"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
type: Mapped[TransactionTypes] = mapped_column(nullable=False)
quantity: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2), nullable=True)
timestamp: Mapped[datetime.datetime] = mapped_column(
nullable=False, default=datetime.datetime.now()
)
total_amount: Mapped[decimal.Decimal] = mapped_column(
Numeric(10, 2), nullable=False
)
order_id: Mapped[int] = mapped_column(
ForeignKey(TABLE_PREFIX + "order.id"), nullable=True
)
order: Mapped["Order"] = relationship("Order", back_populates="transaction")
account_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "account.id"))
account: Mapped["Account"] = relationship("Account", back_populates="transactions")

View File

@@ -0,0 +1,58 @@
/* frontend/static/css/custom.css */
/* Green theme colors */
:root {
--sidebar-bg: #2d5a3d; /* Dark green background */
--sidebar-link-color: #e0f7e9; /* Light green text */
--sidebar-link-hover: #4caf50; /* Medium green hover */
--main-bg: #f8f9fa; /* Light gray background for main content */
--logo-height: 40px;
}
/* Sidebar styles */
.sidebar {
min-height: 100vh;
background-color: var(--sidebar-bg);
position: sticky;
top: 0;
z-index: 100;
color: var(--sidebar-link-color);
}
.navbar-brand img {
height: var(--logo-height);
}
.sidebar .nav-link {
padding: 10px 15px;
color: var(--sidebar-link-color);
transition: background-color 0.2s;
}
.sidebar .nav-link:hover {
background-color: var(--sidebar-link-hover);
color: white;
}
.sidebar .nav-link.active {
background-color: var(--sidebar-link-hover);
color: white;
}
/* Main content styles */
.main-content {
background-color: var(--main-bg);
min-height: 100vh;
}
/* Mobile responsiveness */
@media (max-width: 767.98px) {
.sidebar {
width: 100%;
position: relative;
min-height: auto;
}
.main-content {
margin-left: 0;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@@ -0,0 +1,110 @@
{% extends "base.html.jinja" %}
{% block content %}
<!-- Area Header -->
<div class="mb-4">
<h2 class="h4 mb-3">{{ area.name }}</h2>
<p class="text-muted">{{ area.description or ''}} </p>
</div>
<!-- Products Grid -->
<div class="row g-3">
{% for product in area.products %}
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<a href="#" class="text-decoration-none text-dark">
<!-- Product Image -->
<img
src="/static/img/{{ product.image_path if product.image_path else 'placeholder.jpg' }}"
alt="{{ product.name }}"
class="card-img-top img-fluid rounded-top"
style="height: 100px; object-fit: cover;"
>
<!-- Product Details -->
<div class="card-body">
<h5 class="card-title mb-2">{{ product.name }}</h5>
<p class="card-text text-muted small mb-3">{{ product.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="fw-bold">{{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }}</span>
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#productModal{{ product.id }}">
In den Warenkorb
</button>
</div>
</div>
</a>
</div>
</div>
<!-- Product Modal -->
<div class="modal fade" id="productModal{{ product.id }}" tabindex="-1" aria-labelledby="productModalLabel{{ product.id }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="POST" action="/shop/cart/add">
<div class="modal-header">
<h5 class="modal-title" id="productModalLabel{{ product.id }}">{{ product.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Product Image -->
<img
src="/static/img/{{ product.image_path if product.image_path else 'placeholder.jpg' }}"
alt="{{ product.name }}"
class="img-fluid rounded mb-3"
style="max-height: 200px; width: 100%; object-fit: cover;"
>
<!-- Product Description -->
<p class="text-muted mb-3">{{ product.description }}</p>
<!-- Price -->
<div class="mb-3">
<h6 class="fw-bold">Preis: {{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }}</h6>
</div>
<!-- Quantity Selector -->
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="area_id" value="{{ area.id }}">
<div class="mb-3">
<label for="quantity{{ product.id }}" class="form-label">Menge{% if product.unit_of_measure != 'piece' %} (in {{product.unit_of_measure}}){% endif %}</label>
<input type="number"
class="form-control"
id="quantity{{ product.id }}"
name="quantity"
value="1"
{% if product.allow_fractional %}min="0.01"{% else %}min="1"{% endif %}
max="999"
{% if product.allow_fractional %}step="0.01"{% else %}step="1"{% endif %}
oninput="updateTotal{{ product.id }}(this.value)"
style="max-width: 150px;">
</div>
<!-- Total Price -->
<div class="mb-3">
<h6>Gesamt: <span id="total{{ product.id }}" class="fw-bold">{{ '%.2f' | format(product.price) }} €</span></h6>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">
In den Warenkorb legen
</button>
</div>
</form>
</div>
</div>
</div>
{# TODO: Make this a global function #}
<script>
function updateTotal{{ product.id }}(quantity) {
const price = {{ product.price }};
const total = (price * quantity).toFixed(2);
document.getElementById('total{{ product.id }}').textContent = total + ' €';
}
</script>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,82 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Allmende Bezahlsystem</title>
<!-- Bootstrap 5 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/static/css/aps.css" rel="stylesheet">
</head>
<body>
<div class="container-fluid">
<div class="row">
<!-- Left Sidebar (always visible, stacks on mobile) -->
<div class="col-md-3 col-lg-2 p-0 sidebar">
<div class="d-flex flex-column p-3">
<a href="/" class="navbar-brand d-flex align-items-center mb-4">
<img src="/static/img/Logo.png" alt="Logo" class="me-2">
<span class="fs-4">APS</span>
</a>
<ul class="nav nav-pills flex-column mb-auto">
<li class="nav-item">
<a href="/" class="nav-link{% if request.url.path == "/"%} active{% endif %}">
Übersicht
</a>
</li>
<li class="nav-item">
<a href="/shop" class="nav-link{% if request.url.path.startswith("/shop")%} active{% endif %}">
Einkaufen
</a>
</li>
{% if request.state.user.has_permission("product", "edit") %}
<li class="nav-item">
<a href="/admin/products" class="nav-link{% if request.url.path.startswith("/admin/products")%} active{% endif %}">
Produktverwaltung
</a>
</li>
{% endif %}
{% if request.state.user.has_permission("user", "edit") %}
<li class="nav-item">
<a href="/admin/users" class="nav-link{% if request.url.path.startswith("/admin/users")%} active{% endif %}">
Nutzerverwaltung
</a>
</li>
<li class="nav-item">
<a href="/admin/groups" class="nav-link{% if request.url.path.startswith("/admin/groups")%} active{% endif %}">
Gruppenverwaltung
</a>
</li>
{% endif %}
</ul>
<!-- Shopping Cart at Bottom -->
<div class="mt-auto pt-3 border-top">
<a href="/shop/cart" class="btn btn-primary w-100 position-relative">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-cart3 me-2" viewBox="0 0 16 16">
<path d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5M3.102 4l.84 4.479 9.144-.459L13.89 4zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4m7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4m-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2m7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
</svg>
Warenkorb
<span class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-danger">
{{ request.state.user.shopping_cart.items|length }}
<span class="visually-hidden">Artikel im Warenkorb</span>
</span>
</a>
</div>
</div>
</div>
<!-- Main Content -->
<main class="col-md-9 ms-sm-auto col-lg-10 p-md-4 main-content">
{% block content %}
{% endblock %}
</main>
</div>
</div>
<!-- Bootstrap 5 JS Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
</body>
</html>

View File

@@ -0,0 +1,94 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="mb-4">
<h2 class="h4 mb-3">Gruppen verwalten</h2>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#createGroupModal">Neue Gruppe erstellen</button>
</div>
<!-- Modal for creating new group -->
<div class="modal fade" id="createGroupModal" tabindex="-1" aria-labelledby="createGroupModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="createGroupModalLabel">Neue Gruppe erstellen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/admin/groups/create">
<div class="modal-body">
<div class="mb-3">
<label for="groupName" class="form-label">Gruppenname</label>
<input type="text" class="form-control" id="groupName" name="name" required>
</div>
<div class="mb-3">
<label for="groupDescription" class="form-label">Beschreibung</label>
<input type="text" class="form-control" id="groupDescription" name="description">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Erstellen</button>
</div>
</form>
</div>
</div>
</div>
{% if groups|length == 0 %}
<div class="alert alert-info">Keine Gruppen vorhanden.</div>
{% else %}
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Beschreibung</th>
<th></th>
</tr>
</thead>
<tbody>
{% for group in groups %}
<tr>
<td>{{ group.id }}</td>
<td>{{ group.name }}</td>
<td>{{ group.description }}</td>
<td>
{% for permission in group.permissions %}
<span class="badge bg-secondary me-1">{{ permission.scope }}:{{ permission.action }} <a class="btn btn-close btn-close-white ms-1" aria-label="Remove" href="/admin/groups/{{ group.id }}/remove_permission/{{ permission.id }}"></a></span>
{% endfor %}
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#addPermissionModal{{ group.id }}">Berechtigung hinzufügen</button>
<a class="btn btn-danger tn-sm me-1" href="/admin/groups/{{ group.id }}/delete">Gruppe löschen</a>
</td>
</tr>
<!-- Modal for adding permission -->
<div class="modal fade" id="addPermissionModal{{ group.id }}" tabindex="-1" aria-labelledby="addPermissionModalLabel{{ group.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addPermissionModalLabel{{ group.id }}">Berechtigung zu Gruppe hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/admin/groups/{{ group.id }}/add_permission">
<div class="modal-body">
<div class="mb-3">
<label for="permissionInput{{ group.id }}" class="form-label">Berechtigung eingeben</label>
<input type="text" class="form-control" id="permissionInput{{ group.id }}" name="permission" placeholder="scope:action" required>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,77 @@
{% extends "base.html.jinja" %}
{% block content %}
<!-- Account Balances Section -->
<div class="mb-4">
<h2 class="h4 mb-3">Meine Konten</h2>
<div class="row g-3">
{% if user.accounts|length > 0 %}
{% for account in user.accounts %}
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-body">
<h5 class="card-title">{{ account.name }}</h5>
<p class="h3 mb-2 {% if account.balance < 0 %}text-danger{% else %}text-success{% endif %}">
{{ '%.2f' | format(account.balance) }} €
</p>
<div class="d-flex gap-2 mt-3">
<a href="#" class="btn btn-sm btn-outline-primary">Transaktionen</a>
<a href="#" class="btn btn-sm btn-success">Aufladen</a>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="col-12">
<div class="alert alert-info">
Keine Konten verfügbar
</div>
</div>
{% endif %}
</div>
</div>
<!-- Latest Transactions Section -->
<div class="transactions-section">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="h3 mb-0">Letzte Buchungen</h2>
<a href="#" class="btn btn-outline-primary btn-sm">Alle ansehen</a>
</div>
<div class="card">
<div class="list-group list-group-flush">
{% if transactions and transactions|length > 0 %}
{% for transaction in transactions[:10] %}
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
<div class="flex-grow-1">
<div class="fw-semibold d-inline">{{ transaction.type|transaction_type_de }}</div>
<small class="text-muted d-inline ms-2">
{{ transaction.timestamp | timestamp_de }}
</small>
{% if transaction.type == "order" %}
<div class="mt-2">
<a href="/shop/order/{{ transaction.order_id }}" class="btn btn-sm btn-outline-primary">Einkauf ansehen</a>
</div>
{% endif %}
</div>
<div class="text-end ms-3">
<span class="fs-5 fw-bold {% if transaction.total_amount < 0 %}text-danger{% else %}text-success{% endif %}">
{{ '%+.2f' | format(transaction.total_amount | default(0)) }} €
</span>
{% if transaction.quantity %}
<div class="small text-muted">{{ transaction.quantity }} €</div>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<div class="list-group-item text-center py-5 text-muted">
<p class="mb-0">Noch keine Buchungen</p>
<small>Deine Buchungen werden hier erscheinen</small>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,86 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="mb-4">
<h2 class="h4 mb-3">{% if is_cart %}Warenkorb{% else %}Einkauf #{{ order.id }}{% endif %}</h2>
{% if not is_cart %}<p class="text-muted">Einkauf abgeschickt: {{ order.transaction.timestamp | timestamp_de }}</p>{% endif %}
</div>
{% set items = order.items %}
{% if items|length == 0 %}
<div class="alert alert-info">Dein Warenkorb ist leer. <a href="/shop" class="alert-link">Weiter einkaufen</a>.</div>
{% else %}
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>Artikel</th>
<th class="text-center">Menge</th>
<th class="text-end">Einzelpreis</th>
<th class="text-end">Summe</th>
<th></th>
</tr>
</thead>
<tbody>
{% set total = namespace(value=0) %}
{% for item in items %}
{% set price = item.price if item.price is defined else (item.unit_price if item.unit_price is defined else 0) %}
{% set subtotal = price * item.quantity %}
{% set total.value = total.value + subtotal %}
<tr>
<td>
<div class="fw-semibold">{{ item.product.name }}</div>
<div class="text-muted small">{{ item.description or '' }}</div>
</td>
<td class="text-center" style="width:180px;">
{% if is_cart %}
<form method="post" action="/shop/cart/update/{{ item.id }}" class="d-flex align-items-center justify-content-center">
<input
type="number"
name="quantity"
value="{% if item.product.allow_fractional %}{{ item.quantity | format_number }}{% else %}{{ item.quantity | int }}{% endif %}"
{% if item.product.allow_fractional %}min="0.01" step="0.01"{% else %}min="1" step="1"{% endif %}
class="form-control form-control-sm me-2"
style="width:80px;"
required>
{% if item.product.unit_of_measure != 'piece' %}<span class="text-muted small ms-1 me-2">{{ item.product.unit_of_measure }}</span>{% endif %}
<button class="btn btn-sm btn-outline-secondary" type="submit">Aktualisieren</button>
</form>
{% else %}
{{ item.quantity | format_number }}{% if item.product.unit_of_measure != 'piece' %} {{ item.product.unit_of_measure }}{% endif %}
{% endif %}
</td>
<td class="text-end">{{ item.product.price | format_number }} €</td>
<td class="text-end">{{ item.total_amount | format_number }} €</td>
<td class="text-end">
{% if is_cart %}<a href="/shop/cart/remove/{{ item.id }}" class="btn btn-sm btn-outline-danger">Entfernen</a>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-end fw-semibold">Gesamtsumme</td>
<td class="text-end fw-bold">{{ order.total_amount | format_number }} €</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
{% if is_cart %}
<div class="d-flex justify-content-between align-items-center mt-3">
<div>
<a href="/shop/finalize_order" class="btn btn-primary">Jetzt Buchen</a>
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block styles %}
<style>
.card { border: none; transition: all 0.3s ease; }
</style>
{% endblock %}

View File

@@ -0,0 +1,57 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="mb-4">
<h2 class="h4 mb-3">Produkt {% if not edit_mode %}erstellen{% else %}bearbeiten{% endif %}</h2>
</div>
<form method="post">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" {% if edit_mode %}value="{{ product.name }}"{% endif %} required>
</div>
<div class="mb-3">
<label for="price" class="form-label">Preis</label>
<input type="number" step="0.01" class="form-control" id="price" name="price" {% if edit_mode %}value="{{ product.price }}"{% endif %} required>
</div>
<div class="mb-3">
<label for="unit_of_measure" class="form-label">Einheit</label>
<select class="form-select" id="unit_of_measure" name="unit_of_measure" required>
<option value="g" {% if edit_mode and product.unit_of_measure == 'g' %}selected{% endif %}>g</option>
<option value="kg" {% if edit_mode and product.unit_of_measure == 'kg' %}selected{% endif %}>kg</option>
<option value="l" {% if edit_mode and product.unit_of_measure == 'l' %}selected{% endif %}>l</option>
<option value="piece" {% if edit_mode and product.unit_of_measure == 'piece' %}selected{% endif %}>Stück</option>
</select>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="allow_fractional" name="allow_fractional" {% if edit_mode and product.allow_fractional %}checked{% endif %}>
<label class="form-check-label" for="allow_fractional">Bruchteile erlauben</label>
</div>
<div class="mb-3">
<label for="vat_rate" class="form-label">MwSt-Satz</label>
<select class="form-select" id="vat_rate" name="vat_rate" required>
<option value="7" {% if edit_mode and product.vat_rate == '7' %}selected{% endif %}>7 %</option>
<option value="19" {% if edit_mode and product.vat_rate == '19' %}selected{% endif %}>19 %</option>
</select>
</div>
<div class="mb-3">
<label for="area" class="form-label">Bereich</label>
<select class="form-select" id="area" name="area_id" required>
{% for area in areas %}
<option value="{{ area.id }}" {% if edit_mode and area.id == product.area_id %}selected{% endif %}>{{ area.name }}</option>
{% endfor %}
</select>
</div>
<!-- <div class="mb-3">
<label for="image_path" class="form-label">Bild-Pfad</label>
<input class="form-control" type="file" id="image_path" accept="image/*">
</div> -->
<button type="submit" class="btn btn-primary">Speichern</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="mb-4">
<h2 class="h4 mb-3">Produkte verwalten</h2>
<a class="btn btn-primary" href="/admin/products/new">Neues Produkt erstellen</a>
</div>
{% if products|length == 0 %}
<div class="alert alert-info">Keine Produkte vorhanden.</div>
{% else %}
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Bereich</th>
<th>Preis</th>
<th>Einheit</th>
<th>Steuersatz</th>
<th></th>
</tr>
</thead>
<tbody>
{% for product in products %}
<tr>
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.area.name }}</td>
<td>{{ product.price | format_number}} €</td>
<td>{{ product.unit_of_measure | units_of_measure_de}}</td>
<td>{{ product.vat_rate | int }} %</td>
<td class="text-end">
<a class="btn btn-sm btn-primary" href="/admin/products/edit/{{ product.id }}">Bearbeiten</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "base.html.jinja" %}
{% block content %}
<!-- Shop Landing Page Header -->
<div class="mb-4">
<h2 class="h4 mb-3">Shop</h2>
<p class="text-muted">In welchem Bereich möchtest du einkaufen?</p>
</div>
<div class="row g-3">
{% for area in areas %}
<div class="col-md-6 col-lg-4">
<a href="/shop/area/{{ area.id }}" class="text-decoration-none">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body d-flex align-items-center p-3">
<!-- Image on the left -->
<div class="me-3" style="width: 120px; flex-shrink: 0;">
<img
src="/static/img/{{ area.image_path if area.image_path !='' else 'placeholder.png'}}" }}"
alt="{{ area.name }}"
class="img-fluid rounded"
style="max-height: 100px; width: 100%; object-fit: contain;"
>
</div>
<!-- Title and description on the right -->
<div class="flex-grow-1">
<h5 class="card-title mb-1">{{ area.name }}</h5>
<p class="card-text text-muted small mb-0">{{ area.description or '' }}</p>
</div>
</div>
</div>
</a>
</div>
{% endfor %}
</div>
<!-- Optional: Featured Products Section -->
{# <div class="mt-5">#}
{# <div class="d-flex justify-content-between align-items-center mb-3">#}
{# <h2 class="h4 mb-0">Featured Products</h2>#}
{# <a href="#" class="btn btn-outline-primary btn-sm">View All</a>#}
{# </div>#}
{# <div class="alert alert-info">#}
{# Featured products will appear here#}
{# </div>#}
{# </div>#}
{% endblock %}
{% block styles %}
<style>
/* Hover effect for shop tiles */
.hover-shadow:hover {
transform: translateY(-3px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
transition: all 0.3s ease;
}
.card {
border: none;
transition: all 0.3s ease;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="mb-4">
<h2 class="h4 mb-3">Benutzer verwalten</h2>
</div>
{% if users|length == 0 %}
<div class="alert alert-info">Keine Benutzer vorhanden.</div>
{% else %}
<div class="table-responsive">
<table class="table align-middle">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Azeigename</th>
<th>Gruppen</th>
<th></th>
</tr>
</thead>
<tbody>
{% for user in users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.username }}</td>
<td>{{ user.display_name }}</td>
<td>
{% for group in user.user_groups %}
<span class="badge bg-secondary me-1">{{ group.name }} <a class="btn btn-close btn-close-white ms-1" aria-label="Remove" href="/admin/users/{{ user.id }}/remove_group/{{ group.id }}"></a></span>
{% endfor %}
</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-primary me-1" data-bs-toggle="modal" data-bs-target="#addGroupModal{{ user.id }}">Gruppe hinzufügen</button>
</td>
</tr>
<!-- Modal for adding group -->
<div class="modal fade" id="addGroupModal{{ user.id }}" tabindex="-1" aria-labelledby="addGroupModalLabel{{ user.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="addGroupModalLabel{{ user.id }}">{{ user.display_name }} zu Gruppe hinzufügen</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/admin/users/{{ user.id }}/add_group">
<div class="modal-body">
<div class="mb-3">
<label for="groupSelect{{ user.id }}" class="form-label">Gruppe auswählen</label>
<select class="form-select" id="groupSelect{{ user.id }}" name="group_id" required>
{% for group in all_groups %}
{% if group not in user.user_groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endif %}
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">Hinzufügen</button>
</div>
</form>
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,29 @@
import locale
import numbers
from starlette.templating import Jinja2Templates
TRANSACTION_TYPE_DE = {
"deposit": "Einzahlung",
"withdrawal": "Auszahlung",
"expense": "Auslage",
"order": "Einkauf",
}
UNITS_OF_MEASURE = {"piece": "Stück"}
def format_number(value: float):
try:
return f"{value:.2f}".replace(".", ",")
except TypeError:
return value
def get_jinja_renderer() -> Jinja2Templates:
renderer = Jinja2Templates(directory="src/allmende_payment_system/templates")
renderer.env.filters["transaction_type_de"] = lambda x: TRANSACTION_TYPE_DE[x]
renderer.env.filters["units_of_measure_de"] = lambda x: UNITS_OF_MEASURE.get(x, x)
renderer.env.filters["format_number"] = format_number
renderer.env.filters["timestamp_de"] = lambda x: x.strftime("%d.%m.%Y %H:%M")
return renderer

View File

@@ -0,0 +1,25 @@
import typing
from decimal import Decimal
from pydantic import BaseModel
UnitsOfMeasure = typing.Literal[
"g",
"kg",
"l",
"piece",
]
class Product(BaseModel):
name: str
price: Decimal
area_id: int
vat_rate: Decimal
allow_fractional: bool = False
unit_of_measure: UnitsOfMeasure
class UserGroup(BaseModel):
name: str
description: typing.Optional[str] = None

80
test/conftest.py Normal file
View File

@@ -0,0 +1,80 @@
import os
from unittest import mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import StaticPool, create_engine, event
from sqlalchemy.orm import sessionmaker
from allmende_payment_system.api.dependencies import get_session
from allmende_payment_system.app import app
from allmende_payment_system.models import Base
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# Create a single connection and an outer transaction which we can rollback
# at the end of the test run. Individual tests will use nested transactions
# (SAVEPOINTs) for isolation. This ensures the TestClient (app) and the
# test fixture sessions see the same in-memory database state.
connection = engine.connect()
transaction = connection.begin()
Base.metadata.create_all(bind=connection)
# Bind sessions to the single connection so all sessions share the same DB
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=connection)
test_session = None
def get_test_session():
"""Dependency override for get_session"""
assert test_session is not None, "test_session is not set"
yield test_session
test_session.flush()
app.dependency_overrides[get_session] = get_test_session
class APSTestClient(TestClient):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def post(self, *args, user: str = "test", **kwargs):
with mock.patch.dict(os.environ, {"APS_username": user}, clear=False):
return super().post(*args, **kwargs)
def get(self, *args, user: str = "test", **kwargs):
with mock.patch.dict(os.environ, {"APS_username": user}, clear=False):
return super().get(*args, **kwargs)
@pytest.fixture(scope="session")
def client():
os.environ["APS_username"] = "test"
return APSTestClient(app)
@pytest.fixture(scope="session")
def unauthorized_client():
os.environ.pop("APS_username", None)
return TestClient(app)
@pytest.fixture(scope="function")
def test_db():
"""Provides a database session for direct test usage"""
db = TestSessionLocal()
global test_session
test_session = db
try:
yield db
finally:
test_session = None
db.rollback()
db.close()

31
test/fake_data.py Normal file
View File

@@ -0,0 +1,31 @@
from decimal import Decimal
from faker import Faker
from faker.providers import BaseProvider
fake = Faker()
class MyProvider(BaseProvider):
def product(self) -> dict:
return {
"name": fake.text(max_nb_chars=10),
"price": Decimal(
fake.pyfloat(left_digits=2, right_digits=2, positive=True)
),
"unit_of_measure": fake.random_element(elements=["kg", "g", "l", "piece"]),
"vat_rate": fake.random_element(elements=[7, 19]),
}
def area(self) -> dict:
return {
"name": fake.text(max_nb_chars=10),
"description": fake.text(max_nb_chars=100),
}
def order(self) -> dict:
return {}
# then add new provider to faker instance
fake.add_provider(MyProvider)

130
test/test_admin.py Normal file
View File

@@ -0,0 +1,130 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlalchemy.orm import Session
from allmende_payment_system.app import app
from allmende_payment_system.database import ensure_user
from allmende_payment_system.models import Permission, User, UserGroup
@pytest.fixture
def admin_user(test_db):
user_info = {"username": "admin", "display_name": "The Administrator"}
user = ensure_user(user_info, test_db)
group = UserGroup(id=1, name="Admins")
group.permissions.append(Permission(scope="user", action="edit"))
user.user_groups.append(group)
test_db.add(group)
test_db.flush()
return "admin"
def test_user_add_group(test_db, client, admin_user):
user_info = {"username": "test", "display_name": "Display Test"}
user = ensure_user(user_info, test_db)
group = UserGroup(name="Bosses")
test_db.add(group)
test_db.flush()
assert 0 == len(user.user_groups)
response = client.post(
f"/admin/users/{user.id}/add_group",
data={"group_id": group.id},
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 303
user = test_db.execute(select(User).where(User.username == "test")).scalar()
assert 1 == len(user.user_groups)
assert "Bosses" == user.user_groups[0].name
def test_user_remove_group(test_db, client, admin_user):
user_info = {"username": "test", "display_name": "Display Test"}
user = ensure_user(user_info, test_db)
group = UserGroup(name="Bosses")
test_db.add(group)
user.user_groups.append(group)
test_db.flush()
assert 1 == len(user.user_groups)
response = client.get(
f"/admin/users/{user.id}/remove_group/{group.id}",
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 303
user = test_db.execute(select(User).where(User.username == "test")).scalar()
assert 0 == len(user.user_groups)
def test_group_add_permission(test_db, client, admin_user):
group = test_db.query(UserGroup).scalar()
response = client.post(
f"/admin/groups/{group.id}/add_permission",
data={"permission": "foo:bar"},
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 303
group = test_db.execute(select(UserGroup).where(UserGroup.id == group.id)).scalar()
assert any(
perm.scope == "foo" and perm.action == "bar" for perm in group.permissions
)
def test_group_add_permission_illegal_format(test_db, client, admin_user):
group = test_db.query(UserGroup).scalar()
response = client.post(
f"/admin/groups/{group.id}/add_permission",
data={"permission": "foobar"},
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 400
def test_group_remove_permission(test_db, client, admin_user):
group = test_db.query(UserGroup).scalar()
response = client.get(
f"/admin/groups/{group.id}/remove_permission/1",
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 303
group = test_db.execute(select(UserGroup).where(UserGroup.id == group.id)).scalar()
assert 0 == len(group.permissions)
def test_create_group(test_db, client, admin_user):
response = client.post(
"/admin/groups/create",
data={"name": "New Group", "description": "A newly created group"},
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 303
assert test_db.query(UserGroup).filter_by(name="New Group").scalar() is not None
def test_delete_group(test_db, client, admin_user):
group = UserGroup(name="To Be Deleted")
test_db.add(group)
test_db.flush()
response = client.get(f"/admin/groups/{group.id}/delete", user=admin_user)
assert response.status_code == 200
assert (
test_db.execute(select(UserGroup).where(UserGroup.id == group.id)).scalar()
is None
)

11
test/test_auth.py Normal file
View File

@@ -0,0 +1,11 @@
from allmende_payment_system.models import Account
def test_unauthorized_access(unauthorized_client, test_db):
response = unauthorized_client.get("/")
assert response.status_code == 401
def test_authorized_access(client, test_db):
response = client.get("/")
assert response.status_code == 200

19
test/test_database.py Normal file
View File

@@ -0,0 +1,19 @@
from sqlalchemy import func, select
from allmende_payment_system.database import ensure_user
from allmende_payment_system.models import User
def test_ensure_user(test_db):
user_info = {"username": "test", "display_name": "Test User"}
user = ensure_user(user_info, test_db)
assert user.username == "test"
test_db.flush()
assert test_db.scalar(select(func.count()).select_from(User)) == 1
user = ensure_user(user_info, test_db)
assert test_db.scalar(select(func.count()).select_from(User)) == 1

46
test/test_models.py Normal file
View File

@@ -0,0 +1,46 @@
import pytest
from allmende_payment_system.models import Account, Permission, User, UserGroup
@pytest.fixture(scope="function")
def test_user(test_db):
user = User(username="test", display_name="Test User")
test_db.add(user)
test_db.flush()
return user
def test_user_model(test_db, test_user):
assert test_user.id is not None
account = Account(name="Test Account")
account.users.append(test_user)
test_db.add(account)
test_db.flush()
assert len(test_user.accounts) == 1
def test_user_shopping_cart_new(test_db, test_user):
cart = test_user.shopping_cart
assert len(cart.items) == 0
def test_user_permissions(test_db):
user = User(username="normie", display_name="Normal User")
admin = User(username="admin", display_name="Admin User")
test_db.add(user)
group = UserGroup(name="Admins", description="A group for admins")
group.permissions.append(Permission(scope="area", action="edit"))
test_db.add(group)
admin.user_groups.append(group)
test_db.flush()
assert len(admin.user_groups) == 1
assert not user.has_permission("area", "edit")
assert admin.has_permission("area", "edit")

184
test/test_shop.py Normal file
View File

@@ -0,0 +1,184 @@
from decimal import Decimal
from unittest import mock
import pytest
from conftest import APSTestClient
from fake_data import fake
from sqlalchemy import select
from allmende_payment_system.database import ensure_user
from allmende_payment_system.models import (
Account,
Area,
Order,
OrderItem,
Product,
Transaction,
)
def create_user_with_account(test_db, username: str, balance: float | None = None):
user_info = {"username": username, "display_name": f"Display {username}"}
user = ensure_user(user_info, test_db)
account = Account(name=f"Account for {username}")
test_db.add(account)
user.accounts.append(account)
if balance is not None:
user.accounts[0].transactions.append(
Transaction(total_amount=Decimal(balance), type="deposit")
)
test_db.flush()
return user
def add_finalized_order_to_user(test_db, user, product) -> Order:
order = Order(user=user)
total_amount = product.price
order.items.append(
OrderItem(product=product, quantity=1, total_amount=total_amount)
)
order.transaction = Transaction(total_amount=total_amount, type="order")
order.transaction.account = user.accounts[0]
test_db.add(order)
test_db.flush()
return order
@pytest.fixture
def product(test_db):
area = Area(**fake.area())
test_db.add(area)
product = Product(**fake.product())
product.area = area
test_db.add(product)
test_db.flush()
return product
def test_add_item_to_cart(client: APSTestClient, test_db, product):
form_data = {"product_id": product.id, "quantity": 2, "area_id": product.area.id}
response = client.post(
"/shop/cart/add",
data=form_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
follow_redirects=False,
)
assert response.status_code == 302
cart = test_db.scalar(select(Order))
assert len(cart.items) == 1
def test_edit_item_in_cart(client: APSTestClient, test_db, product):
form_data = {"product_id": product.id, "quantity": 2, "area_id": product.area.id}
response = client.post(
"/shop/cart/add",
data=form_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
follow_redirects=False,
)
assert response.status_code == 302
form_data = {"quantity": 3}
response = client.post(
f"/shop/cart/update/{test_db.scalar(select(OrderItem)).id}",
data=form_data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
follow_redirects=False,
)
assert response.status_code == 302
cart = test_db.scalar(select(Order))
assert len(cart.items) == 1
assert cart.items[0].quantity == 3
assert cart.items[0].total_amount == product.price * 3
def test_remove_item_from_cart(client: APSTestClient, test_db, product):
user = create_user_with_account(test_db, "test")
user.shopping_cart.items.append(
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
)
test_db.flush()
cart = test_db.scalar(select(Order))
assert len(cart.items) == 1
response = client.get(
f"/shop/cart/remove/{user.shopping_cart.items[0].id}", follow_redirects=False
)
assert response.status_code == 302
cart = test_db.scalar(select(Order))
assert len(cart.items) == 0
assert len(test_db.scalars(select(OrderItem)).all()) == 0
def test_remove_item_from_cart_wrong_user(client: APSTestClient, test_db, product):
user = create_user_with_account(test_db, "test")
user.shopping_cart.items.append(
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
)
test_db.flush()
cart = test_db.scalar(select(Order))
assert len(cart.items) == 1
id_ = user.shopping_cart.items[0].id
response = client.get(
f"/shop/cart/remove/{id_}", follow_redirects=False, user="other_user"
)
assert response.status_code == 404
cart = test_db.scalar(select(Order))
assert len(cart.items) == 1
def test_finalize_order(client: APSTestClient, test_db, product):
user = create_user_with_account(test_db, "test", balance=100.0)
user.shopping_cart.items.append(
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
)
test_db.flush()
response = client.get("/shop/finalize_order", follow_redirects=False)
assert response.status_code == 302
assert len(user.shopping_cart.items) == 0
assert len(user.orders) == 2 # shopping cart + finalized order
assert user.accounts[0].balance == Decimal(100.0) - (product.price * 2)
def test_view_order(client: APSTestClient, test_db, product):
user = create_user_with_account(test_db, "test")
order = add_finalized_order_to_user(test_db, user, product)
response = client.get(f"/shop/order/{order.id}")
assert response.status_code == 200
assert f"Einkauf #{order.id}" in response.text
assert product.name in response.text
def test_view_order_wrong_user(client: APSTestClient, test_db, product):
user = create_user_with_account(test_db, "test")
order = add_finalized_order_to_user(test_db, user, product)
response = client.get(
f"/shop/order/{order.id}", follow_redirects=False, user="other_user"
)
assert response.status_code == 403

827
uv.lock generated Normal file
View File

@@ -0,0 +1,827 @@
version = 1
revision = 3
requires-python = "==3.13.*"
[[package]]
name = "alembic"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/cc/aca263693b2ece99fa99a09b6d092acb89973eb2bb575faef1777e04f8b4/alembic-1.18.1.tar.gz", hash = "sha256:83ac6b81359596816fb3b893099841a0862f2117b2963258e965d70dc62fb866", size = 2044319, upload-time = "2026-01-14T18:53:14.907Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/36/cd9cb6101e81e39076b2fbe303bfa3c85ca34e55142b0324fcbf22c5c6e2/alembic-1.18.1-py3-none-any.whl", hash = "sha256:f1c3b0920b87134e851c25f1f7f236d8a332c34b75416802d06971df5d1b7810", size = 260973, upload-time = "2026-01-14T18:53:17.533Z" },
]
[[package]]
name = "allmende-payment-system"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "alembic" },
{ name = "fastapi", extra = ["standard"] },
{ name = "sqlalchemy" },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "faker" },
{ name = "httpx" },
{ name = "isort" },
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.18.1" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.119.0" },
{ name = "sqlalchemy", specifier = ">=2.0.44" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=25.9.0" },
{ name = "faker", specifier = ">=38.2.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=7.0.0" },
{ name = "pytest", specifier = ">=8.4.2" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "black"
version = "25.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" },
{ url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" },
{ url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" },
{ url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" },
{ url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "faker"
version = "38.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/27/022d4dbd4c20567b4c294f79a133cc2f05240ea61e0d515ead18c995c249/faker-38.2.0.tar.gz", hash = "sha256:20672803db9c7cb97f9b56c18c54b915b6f1d8991f63d1d673642dc43f5ce7ab", size = 1941469, upload-time = "2025-11-19T16:37:31.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/93/00c94d45f55c336434a15f98d906387e87ce28f9918e4444829a8fda432d/faker-38.2.0-py3-none-any.whl", hash = "sha256:35fe4a0a79dee0dc4103a6083ee9224941e7d3594811a50e3969e547b0d2ee65", size = 1980505, upload-time = "2025-11-19T16:37:30.208Z" },
]
[[package]]
name = "fastapi"
version = "0.119.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/f9/5c5bcce82a7997cc0eb8c47b7800f862f6b56adc40486ed246e5010d443b/fastapi-0.119.0.tar.gz", hash = "sha256:451082403a2c1f0b99c6bd57c09110ed5463856804c8078d38e5a1f1035dbbb7", size = 336756, upload-time = "2025-10-11T17:13:40.53Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/70/584c4d7cad80f5e833715c0a29962d7c93b4d18eed522a02981a6d1b6ee5/fastapi-0.119.0-py3-none-any.whl", hash = "sha256:90a2e49ed19515320abb864df570dd766be0662c5d577688f1600170f7f73cf2", size = 107095, upload-time = "2025-10-11T17:13:39.048Z" },
]
[package.optional-dependencies]
standard = [
{ name = "email-validator" },
{ name = "fastapi-cli", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cli"
version = "0.0.13"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/4e/3f61850012473b097fc5297d681bd85788e186fadb8555b67baf4c7707f4/fastapi_cli-0.0.13.tar.gz", hash = "sha256:312addf3f57ba7139457cf0d345c03e2170cc5a034057488259c33cd7e494529", size = 17780, upload-time = "2025-09-20T16:37:31.089Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/36/7432750f3638324b055496d2c952000bea824259fca70df5577a6a3c172f/fastapi_cli-0.0.13-py3-none-any.whl", hash = "sha256:219b73ccfde7622559cef1d43197da928516acb4f21f2ec69128c4b90057baba", size = 11142, upload-time = "2025-09-20T16:37:29.695Z" },
]
[package.optional-dependencies]
standard = [
{ name = "fastapi-cloud-cli" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cloud-cli"
version = "0.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
{ name = "pydantic", extra = ["email"] },
{ name = "rich-toolkit" },
{ name = "rignore" },
{ name = "sentry-sdk" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/48/0f14d8555b750dc8c04382804e4214f1d7f55298127f3a0237ba566e69dd/fastapi_cloud_cli-0.3.1.tar.gz", hash = "sha256:8c7226c36e92e92d0c89827e8f56dbf164ab2de4444bd33aa26b6c3f7675db69", size = 24080, upload-time = "2025-10-09T11:32:58.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/68/79/7f5a5e5513e6a737e5fb089d9c59c74d4d24dc24d581d3aa519b326bedda/fastapi_cloud_cli-0.3.1-py3-none-any.whl", hash = "sha256:7d1a98a77791a9d0757886b2ffbf11bcc6b3be93210dd15064be10b216bf7e00", size = 19711, upload-time = "2025-10-09T11:32:57.118Z" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
{ url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
{ url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
{ url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
{ url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "isort"
version = "7.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pydantic"
version = "2.12.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/1e/4f0a3233767010308f2fd6bd0814597e3f63f1dc98304a9112b8759df4ff/pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74", size = 819383, upload-time = "2025-10-17T15:04:21.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/6b/83661fa77dcefa195ad5f8cd9af3d1a7450fd57cc883ad04d65446ac2029/pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf", size = 462431, upload-time = "2025-10-17T15:04:19.346Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/18/d0944e8eaaa3efd0a91b0f1fc537d3be55ad35091b6a87638211ba691964/pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5", size = 457557, upload-time = "2025-10-14T10:23:47.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/13/d0/c20adabd181a029a970738dfe23710b52a31f1258f591874fcdec7359845/pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746", size = 2105688, upload-time = "2025-10-14T10:20:54.448Z" },
{ url = "https://files.pythonhosted.org/packages/00/b6/0ce5c03cec5ae94cca220dfecddc453c077d71363b98a4bbdb3c0b22c783/pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced", size = 1910807, upload-time = "2025-10-14T10:20:56.115Z" },
{ url = "https://files.pythonhosted.org/packages/68/3e/800d3d02c8beb0b5c069c870cbb83799d085debf43499c897bb4b4aaff0d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a", size = 1956669, upload-time = "2025-10-14T10:20:57.874Z" },
{ url = "https://files.pythonhosted.org/packages/60/a4/24271cc71a17f64589be49ab8bd0751f6a0a03046c690df60989f2f95c2c/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02", size = 2051629, upload-time = "2025-10-14T10:21:00.006Z" },
{ url = "https://files.pythonhosted.org/packages/68/de/45af3ca2f175d91b96bfb62e1f2d2f1f9f3b14a734afe0bfeff079f78181/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1", size = 2224049, upload-time = "2025-10-14T10:21:01.801Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/ae4e1ff84672bf869d0a77af24fd78387850e9497753c432875066b5d622/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2", size = 2342409, upload-time = "2025-10-14T10:21:03.556Z" },
{ url = "https://files.pythonhosted.org/packages/18/62/273dd70b0026a085c7b74b000394e1ef95719ea579c76ea2f0cc8893736d/pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84", size = 2069635, upload-time = "2025-10-14T10:21:05.385Z" },
{ url = "https://files.pythonhosted.org/packages/30/03/cf485fff699b4cdaea469bc481719d3e49f023241b4abb656f8d422189fc/pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d", size = 2194284, upload-time = "2025-10-14T10:21:07.122Z" },
{ url = "https://files.pythonhosted.org/packages/f9/7e/c8e713db32405dfd97211f2fc0a15d6bf8adb7640f3d18544c1f39526619/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d", size = 2137566, upload-time = "2025-10-14T10:21:08.981Z" },
{ url = "https://files.pythonhosted.org/packages/04/f7/db71fd4cdccc8b75990f79ccafbbd66757e19f6d5ee724a6252414483fb4/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2", size = 2316809, upload-time = "2025-10-14T10:21:10.805Z" },
{ url = "https://files.pythonhosted.org/packages/76/63/a54973ddb945f1bca56742b48b144d85c9fc22f819ddeb9f861c249d5464/pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab", size = 2311119, upload-time = "2025-10-14T10:21:12.583Z" },
{ url = "https://files.pythonhosted.org/packages/f8/03/5d12891e93c19218af74843a27e32b94922195ded2386f7b55382f904d2f/pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c", size = 1981398, upload-time = "2025-10-14T10:21:14.584Z" },
{ url = "https://files.pythonhosted.org/packages/be/d8/fd0de71f39db91135b7a26996160de71c073d8635edfce8b3c3681be0d6d/pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4", size = 2030735, upload-time = "2025-10-14T10:21:16.432Z" },
{ url = "https://files.pythonhosted.org/packages/72/86/c99921c1cf6650023c08bfab6fe2d7057a5142628ef7ccfa9921f2dda1d5/pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564", size = 1973209, upload-time = "2025-10-14T10:21:18.213Z" },
{ url = "https://files.pythonhosted.org/packages/36/0d/b5706cacb70a8414396efdda3d72ae0542e050b591119e458e2490baf035/pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4", size = 1877324, upload-time = "2025-10-14T10:21:20.363Z" },
{ url = "https://files.pythonhosted.org/packages/de/2d/cba1fa02cfdea72dfb3a9babb067c83b9dff0bbcb198368e000a6b756ea7/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2", size = 1884515, upload-time = "2025-10-14T10:21:22.339Z" },
{ url = "https://files.pythonhosted.org/packages/07/ea/3df927c4384ed9b503c9cc2d076cf983b4f2adb0c754578dfb1245c51e46/pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf", size = 2042819, upload-time = "2025-10-14T10:21:26.683Z" },
{ url = "https://files.pythonhosted.org/packages/6a/ee/df8e871f07074250270a3b1b82aad4cd0026b588acd5d7d3eb2fcb1471a3/pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2", size = 1995866, upload-time = "2025-10-14T10:21:28.951Z" },
{ url = "https://files.pythonhosted.org/packages/fc/de/b20f4ab954d6d399499c33ec4fafc46d9551e11dc1858fb7f5dca0748ceb/pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89", size = 1970034, upload-time = "2025-10-14T10:21:30.869Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "python-dotenv"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.20"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
]
[[package]]
name = "pytokens"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/c2/dbadcdddb412a267585459142bfd7cc241e6276db69339353ae6e241ab2b/pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43", size = 15368, upload-time = "2025-10-15T08:02:42.738Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/5a/c269ea6b348b6f2c32686635df89f32dbe05df1088dd4579302a6f8f99af/pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8", size = 12038, upload-time = "2025-10-15T08:02:41.694Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "rich-toolkit"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/33/1a18839aaa8feef7983590c05c22c9c09d245ada6017d118325bbfcc7651/rich_toolkit-0.15.1.tar.gz", hash = "sha256:6f9630eb29f3843d19d48c3bd5706a086d36d62016687f9d0efa027ddc2dd08a", size = 115322, upload-time = "2025-09-04T09:28:11.789Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/49/42821d55ead7b5a87c8d121edf323cb393d8579f63e933002ade900b784f/rich_toolkit-0.15.1-py3-none-any.whl", hash = "sha256:36a0b1d9a135d26776e4b78f1d5c2655da6e0ef432380b5c6b523c8d8ab97478", size = 29412, upload-time = "2025-09-04T09:28:10.587Z" },
]
[[package]]
name = "rignore"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/1a/4e407524cf97ed42a9c77d3cc31b12dd5fb2ce542f174ff7cf78ea0ca293/rignore-0.7.1.tar.gz", hash = "sha256:67bb99d57d0bab0c473261561f98f118f7c9838a06de222338ed8f2b95ed84b4", size = 15437, upload-time = "2025-10-15T20:59:08.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/f8/99145d7ee439db898709b9a7e913d42ed3a6ff679c50a163bae373f07276/rignore-0.7.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:cb6c993b22d7c88eeadc4fed2957be688b6c5f98d4a9b86d3a5057f4a17ea5bd", size = 881743, upload-time = "2025-10-15T20:58:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/fa/db/aea84354518a24578c77d8fec2f42c065520b48ba5bded9d8eca9e46fefd/rignore-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:32da28b0e0434b88134f8d97f22afe6bd1e2a103278a726809e2d8da8426b33f", size = 814397, upload-time = "2025-10-15T20:58:00.071Z" },
{ url = "https://files.pythonhosted.org/packages/12/0b/116afdee4093f0ccd3c4e7b6840d3699ea2a34c1ae6d1dd4d7d9d0adc65b/rignore-0.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:401d52a0a1c5eae342b2c7b4091206e1ce70de54e85c8c8f0ea3309765a62d60", size = 893431, upload-time = "2025-10-15T20:56:45.476Z" },
{ url = "https://files.pythonhosted.org/packages/52/b5/66778c7cbb8e2c6f4ca6f2f59067aa01632b913741c4aa46b163dc4c8f8c/rignore-0.7.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ffcfbef75656243cfdcdd495b0ea0b71980b76af343b1bf3aed61a78db3f145", size = 867220, upload-time = "2025-10-15T20:56:58.931Z" },
{ url = "https://files.pythonhosted.org/packages/6e/da/bdd6de52941391f0056295c6904c45e1f8667df754b17fe880d0a663d941/rignore-0.7.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0e89efa2ad36a9206ed30219eb1a8783a0722ae8b6d68390ae854e5f5ceab6ff", size = 1169076, upload-time = "2025-10-15T20:57:12.153Z" },
{ url = "https://files.pythonhosted.org/packages/0e/8d/d7d4bfbae28e340a6afe850809a020a31c2364fc0ee8105be4ec0841b20a/rignore-0.7.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f6191d7f52894ee65a879f022329011e31cc41f98739ff184cd3f256a3f0711", size = 937738, upload-time = "2025-10-15T20:57:25.497Z" },
{ url = "https://files.pythonhosted.org/packages/d8/b1/1d3f88aaf3cc6f4e31d1d72eb261eff3418dabd2677c83653b7574e7947a/rignore-0.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:873a8e84b4342534b9e283f7c17dc39c295edcdc686dfa395ddca3628316931b", size = 951791, upload-time = "2025-10-15T20:57:49.574Z" },
{ url = "https://files.pythonhosted.org/packages/90/7f/033631f29af972bc4f69e241ab188d21fbc4665ad67879c77bc984009550/rignore-0.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:65443a6a5efd184d21538816282c78c4787a8a5f73c243ab87cbbb6f313a623d", size = 977580, upload-time = "2025-10-15T20:57:39.063Z" },
{ url = "https://files.pythonhosted.org/packages/c7/38/6f963926b769365a803ec17d448a4fc9c2dbad9c1a1bf73c28088021c2fc/rignore-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d6cafca0b422c0d57ce617fed3831e6639dc151653b98396af919f8eb3ba9e2b", size = 1074486, upload-time = "2025-10-15T20:58:18.505Z" },
{ url = "https://files.pythonhosted.org/packages/74/d2/a1c1e2cd3e43f6433d3ecb8d947e1ed684c261fa2e7b2f6b8827c3bf18d1/rignore-0.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:1f731b018b5b5a93d7b4a0f4e43e5fcbd6cf25e97cec265392f9dd8d10916e5c", size = 1131024, upload-time = "2025-10-15T20:58:32.075Z" },
{ url = "https://files.pythonhosted.org/packages/93/22/b7dd8312aa98211df1f10a6cd2a3005e72cd4ac5c125fd064c7e58394205/rignore-0.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3be78b1ab9fa1c0eac822a69a7799a2261ce06e4d548374093c4c64d796d7d8", size = 1109625, upload-time = "2025-10-15T20:58:46.077Z" },
{ url = "https://files.pythonhosted.org/packages/f7/65/dd31859304bd71ad72f71e2bf5f18e6f0043cc75394ead8c0d752ab580ad/rignore-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d8c3b77ae1a24b09a6d38e07d180f362e47b970c767d2e22417b03d95685cb9d", size = 1117466, upload-time = "2025-10-15T20:58:59.102Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d7/e83241e1b0a6caef1e37586d5b2edb0227478d038675e4e6e1cd748c08ce/rignore-0.7.1-cp313-cp313-win32.whl", hash = "sha256:c01cc8c5d7099d35a7fd00e174948986d4f2cfb6b7fe2923b0b801b1a4741b37", size = 635266, upload-time = "2025-10-15T20:59:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/95/e5/c2ce66a71cfc44010a238a61339cae7469adc17306025796884672784b4c/rignore-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:5dd0de4a7d38a49b9d85f332d129b4ca4a29eef5667d4c7bf503e767cf9e2ec4", size = 718048, upload-time = "2025-10-15T20:59:19.312Z" },
{ url = "https://files.pythonhosted.org/packages/ba/fb/b92aa591e247f6258997163e8b1844c9b799371fbfdfd29533e203df06b9/rignore-0.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:4a4c57b75ec758fb31ad1abab4c77810ea417e9d33bdf2f38cf9e6db556eebcb", size = 647790, upload-time = "2025-10-15T20:59:12.408Z" },
{ url = "https://files.pythonhosted.org/packages/b6/d3/b6c5764d3dcaf47de7f0e408dcb4a1a17d4ce3bb1b0aa9a346e221e3c5a1/rignore-0.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:abb7df83a41213069195436e9c1a433a6df85c089ce4be406d070a4db0ee3897", size = 892938, upload-time = "2025-10-15T20:56:46.559Z" },
{ url = "https://files.pythonhosted.org/packages/48/6a/4d8ae9af9936a061dacda0d8f638cd63571ff93e4eb28e0159db6c4dc009/rignore-0.7.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:30d9c9a93a266d1f384465d626178f49d0da4d1a0cf739f15151cdf2eb500e53", size = 867312, upload-time = "2025-10-15T20:57:00.083Z" },
{ url = "https://files.pythonhosted.org/packages/9b/88/cb243662a0b523b4350db1c7c3adee87004af90e9b26100e84c7e13b93cc/rignore-0.7.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e83c68f557d793b4cc7aac943f3b23631469e1bc5b02e63626d0b008be01cd1", size = 1166871, upload-time = "2025-10-15T20:57:13.618Z" },
{ url = "https://files.pythonhosted.org/packages/f6/0a/da28a3f3e8ab1829180f3a7af5b601b04bab1d833e31a74fee78a2d3f5c3/rignore-0.7.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:682a6efe3f84af4b1100d4c68f0a345f490af74fd9d18346ebf67da9a3b96b08", size = 937964, upload-time = "2025-10-15T20:57:27.054Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2e/f55d0759c6cf48d8fabc62d8924ce58dca81f5c370c0abdcc7cc8176210d/rignore-0.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:736b6aa3e3dfda2b1404b6f9a9d6f67e2a89f184179e9e5b629198df7c22f9c6", size = 1073720, upload-time = "2025-10-15T20:58:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/c3/aa/8698caf5eb1824f8cae08cd3a296bc7f6f46e7bb539a4dd60c6a7a9f5ca2/rignore-0.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eed55292d949e99f29cd4f1ae6ddc2562428a3e74f6f4f6b8658f1d5113ffbd5", size = 1130545, upload-time = "2025-10-15T20:58:33.709Z" },
{ url = "https://files.pythonhosted.org/packages/f5/88/89abacdc122f4a0d069d12ebbd87693253f08f19457b77f030c0c6cba316/rignore-0.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:93ce054754857e37f15fe6768fd28e5450a52c7bbdb00e215100b092281ed123", size = 1108570, upload-time = "2025-10-15T20:58:47.438Z" },
{ url = "https://files.pythonhosted.org/packages/c9/4b/a815624ff1f2420ff29be1ffa2ea5204a69d9a9738fe5a6638fcd1069347/rignore-0.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:447004c774083e4f9cddf0aefcb80b12264f23e28c37918fb709917c2aabd00d", size = 1116940, upload-time = "2025-10-15T20:59:00.581Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/b2/7481156cf42b7f66cffb371e504b7ace12b4f016b8872ffcf0873ae9534b/sentry_sdk-2.42.0.tar.gz", hash = "sha256:91c69c9372fb5fb4df0ac39456ccf7286f0428b3ee1cdd389f9dd36c04e0f5c9", size = 351242, upload-time = "2025-10-15T07:41:15.577Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/58/4a/9810a246ec5d1df2ae066efefeecfa91d3c548fa2bd5390184e016112887/sentry_sdk-2.42.0-py2.py3-none-any.whl", hash = "sha256:1a7986e638306ff158f52dd47d9480a4055e6c289388caa90628acb2563fe7bd", size = 379496, upload-time = "2025-10-15T07:41:13.802Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.44"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/f2/840d7b9496825333f532d2e3976b8eadbf52034178aac53630d09fe6e1ef/sqlalchemy-2.0.44.tar.gz", hash = "sha256:0ae7454e1ab1d780aee69fd2aae7d6b8670a581d8847f2d1e0f7ddfbf47e5a22", size = 9819830, upload-time = "2025-10-10T14:39:12.935Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/d3/c67077a2249fdb455246e6853166360054c331db4613cda3e31ab1cadbef/sqlalchemy-2.0.44-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ff486e183d151e51b1d694c7aa1695747599bb00b9f5f604092b54b74c64a8e1", size = 2135479, upload-time = "2025-10-10T16:03:37.671Z" },
{ url = "https://files.pythonhosted.org/packages/2b/91/eabd0688330d6fd114f5f12c4f89b0d02929f525e6bf7ff80aa17ca802af/sqlalchemy-2.0.44-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b1af8392eb27b372ddb783b317dea0f650241cea5bd29199b22235299ca2e45", size = 2123212, upload-time = "2025-10-10T16:03:41.755Z" },
{ url = "https://files.pythonhosted.org/packages/b0/bb/43e246cfe0e81c018076a16036d9b548c4cc649de241fa27d8d9ca6f85ab/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b61188657e3a2b9ac4e8f04d6cf8e51046e28175f79464c67f2fd35bceb0976", size = 3255353, upload-time = "2025-10-10T15:35:31.221Z" },
{ url = "https://files.pythonhosted.org/packages/b9/96/c6105ed9a880abe346b64d3b6ddef269ddfcab04f7f3d90a0bf3c5a88e82/sqlalchemy-2.0.44-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b87e7b91a5d5973dda5f00cd61ef72ad75a1db73a386b62877d4875a8840959c", size = 3260222, upload-time = "2025-10-10T15:43:50.124Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/1857e35a47155b5ad927272fee81ae49d398959cb749edca6eaa399b582f/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:15f3326f7f0b2bfe406ee562e17f43f36e16167af99c4c0df61db668de20002d", size = 3189614, upload-time = "2025-10-10T15:35:32.578Z" },
{ url = "https://files.pythonhosted.org/packages/88/ee/4afb39a8ee4fc786e2d716c20ab87b5b1fb33d4ac4129a1aaa574ae8a585/sqlalchemy-2.0.44-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e77faf6ff919aa8cd63f1c4e561cac1d9a454a191bb864d5dd5e545935e5a40", size = 3226248, upload-time = "2025-10-10T15:43:51.862Z" },
{ url = "https://files.pythonhosted.org/packages/32/d5/0e66097fc64fa266f29a7963296b40a80d6a997b7ac13806183700676f86/sqlalchemy-2.0.44-cp313-cp313-win32.whl", hash = "sha256:ee51625c2d51f8baadf2829fae817ad0b66b140573939dd69284d2ba3553ae73", size = 2101275, upload-time = "2025-10-10T15:03:26.096Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/665617fe4f8c6450f42a6d8d69243f9420f5677395572c2fe9d21b493b7b/sqlalchemy-2.0.44-cp313-cp313-win_amd64.whl", hash = "sha256:c1c80faaee1a6c3428cecf40d16a2365bcf56c424c92c2b6f0f9ad204b899e9e", size = 2127901, upload-time = "2025-10-10T15:03:27.548Z" },
{ url = "https://files.pythonhosted.org/packages/9c/5e/6a29fa884d9fb7ddadf6b69490a9d45fded3b38541713010dad16b77d015/sqlalchemy-2.0.44-py3-none-any.whl", hash = "sha256:19de7ca1246fbef9f9d1bff8f1ab25641569df226364a0e40457dc5457c54b05", size = 1928718, upload-time = "2025-10-10T15:29:45.32Z" },
]
[[package]]
name = "starlette"
version = "0.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" },
]
[[package]]
name = "typer"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/21/ca/950278884e2ca20547ff3eb109478c6baf6b8cf219318e6bc4f666fad8e8/typer-0.19.2.tar.gz", hash = "sha256:9ad824308ded0ad06cc716434705f691d4ee0bfd0fb081839d2e426860e7fdca", size = 104755, upload-time = "2025-09-23T09:47:48.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl", hash = "sha256:755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9", size = 46748, upload-time = "2025-09-23T09:47:46.777Z" },
]
[[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" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "uvicorn"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]