Implement shopping cart

This commit is contained in:
2025-12-05 11:39:51 +01:00
parent f4618f4d05
commit 00246819cc
12 changed files with 288 additions and 36 deletions

View File

@@ -22,6 +22,7 @@ build-backend = "uv_build"
[dependency-groups] [dependency-groups]
dev = [ dev = [
"black>=25.9.0", "black>=25.9.0",
"faker>=38.2.0",
"httpx>=0.28.1", "httpx>=0.28.1",
"isort>=7.0.0", "isort>=7.0.0",
"pytest>=8.4.2", "pytest>=8.4.2",

View File

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

View File

@@ -13,6 +13,7 @@ def get_session() -> Session:
try: try:
yield db yield db
finally: finally:
db.commit()
db.close() db.close()

View File

@@ -1,8 +1,12 @@
from decimal import Decimal
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from sqlalchemy import select from sqlalchemy import select
from starlette import status
from starlette.responses import RedirectResponse
from allmende_payment_system.api import SessionDep from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.models import Area from allmende_payment_system.models import Area, OrderItem, Product
from allmende_payment_system.tools import get_jinja_renderer from allmende_payment_system.tools import get_jinja_renderer
shop_router = APIRouter() shop_router = APIRouter()
@@ -20,6 +24,26 @@ 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 get_cart(request: Request, session: SessionDep, user: UserDep):
cart = user.shopping_cart
# TODO: Implement
cart.finalize_order()
return RedirectResponse(url=f"/", status_code=status.HTTP_302_FOUND)
@shop_router.get("/shop/area/{area_id}") @shop_router.get("/shop/area/{area_id}")
async def get_shop(request: Request, session: SessionDep, area_id: int): async def get_shop(request: Request, session: SessionDep, area_id: int):
query = select(Area).where(Area.id == area_id) query = select(Area).where(Area.id == area_id)
@@ -28,3 +52,27 @@ async def get_shop(request: Request, session: SessionDep, area_id: int):
"area.html.jinja", "area.html.jinja",
context={"request": request, "area": area}, 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

@@ -24,6 +24,6 @@ def ensure_user(user_info: dict, session: Session) -> User:
username=user_info["username"], display_name=user_info.get("display_name") username=user_info["username"], display_name=user_info.get("display_name")
) )
session.add(user) session.add(user)
session.commit() session.flush()
return user return user

View File

@@ -82,6 +82,7 @@ class Area(Base):
UnitsOfMeasure = typing.Literal[ UnitsOfMeasure = typing.Literal[
"g", "g",
"kg", "kg",
"l",
"piece", "piece",
] ]
@@ -121,6 +122,10 @@ class Order(Base):
def is_in_shopping_cart(self): def is_in_shopping_cart(self):
return self.account is None return self.account is None
@property
def total_amount(self):
return sum(item.total_amount for item in self.items)
class OrderItem(Base): class OrderItem(Base):
__tablename__ = TABLE_PREFIX + "order_item" __tablename__ = TABLE_PREFIX + "order_item"
@@ -130,7 +135,7 @@ class OrderItem(Base):
product_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "product.id")) product_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "product.id"))
product: Mapped[Product] = relationship("Product") 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( total_amount: Mapped[decimal.Decimal] = mapped_column(
Numeric(10, 2), nullable=False Numeric(10, 2), nullable=False
) )

View File

@@ -25,12 +25,85 @@
<p class="card-text text-muted small mb-3">{{ product.description }}</p> <p class="card-text text-muted small mb-3">{{ product.description }}</p>
<div class="d-flex justify-content-between align-items-center"> <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> <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>
</div> </div>
</a> </a>
</div> </div>
</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 %} {% endfor %}
</div> </div>
{% endblock %} {% endblock %}

View File

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

@@ -3,34 +3,42 @@ from unittest import mock
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from sqlalchemy import StaticPool, create_engine from sqlalchemy import StaticPool, create_engine, event
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from allmende_payment_system.api.dependencies import get_session from allmende_payment_system.api.dependencies import get_session
from allmende_payment_system.app import app from allmende_payment_system.app import app
from allmende_payment_system.models import Base from allmende_payment_system.models import Base
def make_db():
engine = create_engine( engine = create_engine(
"sqlite:///:memory:", "sqlite:///:memory:",
connect_args={"check_same_thread": False}, connect_args={"check_same_thread": False},
poolclass=StaticPool, 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)
def make_in_memory_session(): def get_test_session():
db = make_db() """Dependency override for get_session"""
session = db() db = TestSessionLocal()
try: try:
yield session yield db
finally: finally:
session.close() db.close()
app.dependency_overrides[get_session] = make_in_memory_session app.dependency_overrides[get_session] = get_test_session
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
@@ -45,7 +53,25 @@ def unauthorized_client():
return TestClient(app) return TestClient(app)
@pytest.fixture @pytest.fixture(scope="function")
def test_db(): def test_db():
db = make_db() """Provides a database session for direct test usage"""
return db() db = TestSessionLocal()
# Start a SAVEPOINT so test changes can be rolled back without
# closing the shared connection. Also restart the nested transaction
# when the session issues commits internally.
db.begin_nested()
@event.listens_for(db, "after_transaction_end")
def restart_savepoint(session, transaction):
# If the nested transaction ended, re-open it for continued isolation
if transaction.nested and not session.is_active:
session.begin_nested()
try:
yield db
finally:
# Rollback to the SAVEPOINT and close the session to clean up
db.rollback()
db.close()

View File

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

25
uv.lock generated
View File

@@ -14,6 +14,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "black" }, { name = "black" },
{ name = "faker" },
{ name = "httpx" }, { name = "httpx" },
{ name = "isort" }, { name = "isort" },
{ name = "pytest" }, { name = "pytest" },
@@ -28,6 +29,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "black", specifier = ">=25.9.0" }, { name = "black", specifier = ">=25.9.0" },
{ name = "faker", specifier = ">=38.2.0" },
{ name = "httpx", specifier = ">=0.28.1" }, { name = "httpx", specifier = ">=0.28.1" },
{ name = "isort", specifier = ">=7.0.0" }, { name = "isort", specifier = ">=7.0.0" },
{ name = "pytest", specifier = ">=8.4.2" }, { 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" }, { 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]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.119.0" 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/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/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/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" }, { 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" }, { 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]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.5.0" version = "2.5.0"