Compare commits

..

2 Commits

Author SHA1 Message Date
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
16 changed files with 430 additions and 52 deletions

View File

@@ -22,6 +22,7 @@ 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",

View File

@@ -11,7 +11,6 @@ templates = get_jinja_renderer()
@root_router.get("/")
async def landing_page(request: Request, user: UserDep, session: SessionDep):
print(f"User {user.username} ({user.display_name}) accessed landing page")
transactions = []
for account in user.accounts:
transactions += account.transactions

View File

@@ -13,6 +13,7 @@ def get_session() -> Session:
try:
yield db
finally:
db.commit()
db.close()
@@ -40,4 +41,4 @@ async def get_user_object(request: Request, session: SessionDep) -> User:
return user
UserDep = Annotated[dict, Depends(get_user_object)]
UserDep = Annotated[User, Depends(get_user_object)]

View File

@@ -1,8 +1,12 @@
from decimal import Decimal
from fastapi import APIRouter, Request
from sqlalchemy import select
from starlette import status
from starlette.responses import RedirectResponse
from allmende_payment_system.api import SessionDep
from allmende_payment_system.models import Area
from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.models import Area, OrderItem, Product
from allmende_payment_system.tools import get_jinja_renderer
shop_router = APIRouter()
@@ -20,11 +24,55 @@ async def get_shop(request: Request, session: SessionDep):
)
@shop_router.get("/shop/cart")
async def get_cart(request: Request, session: SessionDep, user: UserDep):
return templates.TemplateResponse(
"cart.html.jinja",
context={"request": request, "user": user},
)
@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(request: Request, session: SessionDep, area_id: int):
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
)

View File

@@ -15,6 +15,18 @@ def create_tables():
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():
@@ -24,6 +36,6 @@ def ensure_user(user_info: dict, session: Session) -> User:
username=user_info["username"], display_name=user_info.get("display_name")
)
session.add(user)
session.commit()
session.flush()
return user

View File

@@ -58,7 +58,7 @@ class User(Base):
@property
def shopping_cart(self):
for order in self.orders:
if order.account_id is None:
if order.transaction is None:
cart = order
break
else:
@@ -82,6 +82,7 @@ class Area(Base):
UnitsOfMeasure = typing.Literal[
"g",
"kg",
"l",
"piece",
]
@@ -108,10 +109,7 @@ class Order(Base):
user_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "user.id"))
user: Mapped[User] = relationship("User", back_populates="orders")
account_id: Mapped[int] = mapped_column(
ForeignKey(TABLE_PREFIX + "account.id"), nullable=True
)
account: Mapped[Account | None] = relationship("Account")
transaction: Mapped["Transaction | None"] = relationship("Transaction")
items: Mapped[list["OrderItem"]] = relationship(
"OrderItem", cascade="all, delete-orphan", back_populates="order"
@@ -119,7 +117,36 @@ class Order(Base):
@property
def is_in_shopping_cart(self):
return self.account is None
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):
@@ -130,14 +157,14 @@ class OrderItem(Base):
product_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "product.id"))
product: Mapped[Product] = relationship("Product")
quantity: Mapped[int] = mapped_column(nullable=False)
quantity: Mapped[decimal.Decimal] = mapped_column(nullable=False)
total_amount: Mapped[decimal.Decimal] = mapped_column(
Numeric(10, 2), nullable=False
)
TransactionTypes = typing.Literal[
"product",
"order",
"deposit",
"withdrawal",
"expense",
@@ -156,10 +183,10 @@ class Transaction(Base):
Numeric(10, 2), nullable=False
)
product_id: Mapped[int] = mapped_column(
ForeignKey(TABLE_PREFIX + "product.id"), nullable=True
order_id: Mapped[int] = mapped_column(
ForeignKey(TABLE_PREFIX + "order.id"), nullable=True
)
product: Mapped["Product"] = relationship("Product")
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

@@ -25,12 +25,85 @@
<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">Add to Cart</button>
<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</label>
<input type="number"
class="form-control"
id="quantity{{ product.id }}"
name="quantity"
value="1"
min="1"
max="999"
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

@@ -29,21 +29,21 @@
Einkaufen
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link">
Lorem
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link">
Ipsum
</a>
</li>
{# <li class="nav-item">#}
{# <a href="#" class="nav-link">#}
{# Lorem#}
{# </a>#}
{# </li>#}
{# <li class="nav-item">#}
{# <a href="#" class="nav-link">#}
{# Ipsum#}
{# </a>#}
{# </li>#}
</ul>
<!-- Shopping Cart at Bottom -->
<div class="mt-auto pt-3 border-top">
<a href="/cart" class="btn btn-primary w-100 position-relative">
<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>

View File

@@ -0,0 +1,74 @@
{% extends "base.html.jinja" %}
{% block content %}
<div class="mb-4">
<h2 class="h4 mb-3">Warenkorb</h2>
<p class="text-muted">Überprüfe deine Artikel und fahre zur Kasse fort.</p>
</div>
{% set items = user.shopping_cart.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 qty = item.quantity if item.quantity is defined else (item.qty if item.qty is defined else 1) %}
{% set subtotal = price * qty %}
{% 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;">
<form method="post" action="/cart/update/{{ item.id }}" class="d-flex align-items-center justify-content-center">
<input type="number" name="quantity" value="{{ qty }}" min="1" step="0.01" class="form-control form-control-sm me-2" style="width:80px;">
<button class="btn btn-sm btn-outline-secondary" type="submit">Aktualisieren</button>
</form>
</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">
<a href="/cart/remove/{{ item.id }}" class="btn btn-sm btn-outline-danger">Entfernen</a>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr>
<td colspan="3" class="text-end fw-semibold">Gesamtsumme</td>
<td class="text-end fw-bold">{{ user.shopping_cart.total_amount | format_number }} €</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<div>
<a href="/cart/clear" class="btn btn-outline-danger me-2">Warenkorb leeren</a>
<a href="/shop/finalize_order" class="btn btn-primary">Jetzt Buchen</a>
</div>
</div>
{% endif %}
{% endblock %}
{% block styles %}
<style>
.card { border: none; transition: all 0.3s ease; }
</style>
{% endblock %}

View File

@@ -45,7 +45,7 @@
{% 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">{{ transaction.product.name or transaction.type|transaction_type_de }}</div>
<div class="fw-semibold">{{ transaction.type|transaction_type_de }}</div>
<small class="text-muted">
{{ transaction.timestamp }}
</small>

View File

@@ -7,7 +7,7 @@ TRANSACTION_TYPE_DE = {
"deposit": "Einzahlung",
"withdrawal": "Auszahlung",
"expense": "Auslage",
"product": "Einkauf",
"order": "Einkauf",
}
UNITS_OF_MEASURE = {"piece": "Stück"}

View File

@@ -3,34 +3,42 @@ from unittest import mock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import StaticPool, create_engine
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
def make_db():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine) # Create tables
return sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 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 make_in_memory_session():
db = make_db()
session = db()
try:
yield session
finally:
session.close()
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] = make_in_memory_session
app.dependency_overrides[get_session] = get_test_session
@pytest.fixture(scope="session")
@@ -45,7 +53,15 @@ def unauthorized_client():
return TestClient(app)
@pytest.fixture
@pytest.fixture(scope="function")
def test_db():
db = make_db()
return 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)

View File

@@ -11,7 +11,7 @@ def test_ensure_user(test_db):
assert user.username == "test"
test_db.commit()
test_db.flush()
assert test_db.scalar(select(func.count()).select_from(User)) == 1

71
test/test_shop.py Normal file
View File

@@ -0,0 +1,71 @@
from decimal import Decimal
from fake_data import fake
from starlette.testclient import TestClient
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 test_add_item_to_cart(client: TestClient, test_db):
area = Area(**fake.area())
test_db.add(area)
product = Product(**fake.product())
product.area = area
test_db.add(product)
test_db.flush()
form_data = {"product_id": product.id, "quantity": 2, "area_id": 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
def test_finalize_order(client: TestClient, test_db):
area = Area(**fake.area())
test_db.add(area)
product = Product(**fake.product())
product.area = area
test_db.add(product)
test_db.flush()
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)

25
uv.lock generated
View File

@@ -14,6 +14,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "faker" },
{ name = "httpx" },
{ name = "isort" },
{ name = "pytest" },
@@ -28,6 +29,7 @@ requires-dist = [
[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" },
@@ -128,6 +130,18 @@ 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"
@@ -204,6 +218,8 @@ wheels = [
{ 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" },
]
@@ -672,6 +688,15 @@ 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"