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] [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()
@@ -40,4 +41,4 @@ async def get_user_object(request: Request, session: SessionDep) -> User:
return 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 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,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}") @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) query = select(Area).where(Area.id == area_id)
area = session.scalars(query).one() area = session.scalars(query).one()
return templates.TemplateResponse( return templates.TemplateResponse(
"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

@@ -15,6 +15,18 @@ def create_tables():
def ensure_user(user_info: dict, session: Session) -> User: 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"]) statement = select(User).where(User.username == user_info["username"])
if user := session.scalars(statement).one_or_none(): 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") 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

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

@@ -45,7 +45,7 @@
{% for transaction in transactions[:10] %} {% for transaction in transactions[:10] %}
<div class="list-group-item d-flex justify-content-between align-items-start py-3"> <div class="list-group-item d-flex justify-content-between align-items-start py-3">
<div class="flex-grow-1"> <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"> <small class="text-muted">
{{ transaction.timestamp }} {{ transaction.timestamp }}
</small> </small>

View File

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

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
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
def make_db(): # Create a single connection and an outer transaction which we can rollback
engine = create_engine( # at the end of the test run. Individual tests will use nested transactions
"sqlite:///:memory:", # (SAVEPOINTs) for isolation. This ensures the TestClient (app) and the
connect_args={"check_same_thread": False}, # test fixture sessions see the same in-memory database state.
poolclass=StaticPool, connection = engine.connect()
) transaction = connection.begin()
Base.metadata.create_all(bind=engine) # Create tables
return sessionmaker(autocommit=False, autoflush=False, bind=engine) 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(): def get_test_session():
db = make_db() """Dependency override for get_session"""
session = db() assert test_session is not None, "test_session is not set"
try: yield test_session
yield session test_session.flush()
finally:
session.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,15 @@ 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()
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" 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

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] [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"