Compare commits
2 Commits
f4618f4d05
...
fd544fcebc
| Author | SHA1 | Date | |
|---|---|---|---|
| fd544fcebc | |||
| 00246819cc |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
74
src/allmende_payment_system/templates/cart.html.jinja
Normal file
74
src/allmende_payment_system/templates/cart.html.jinja
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ TRANSACTION_TYPE_DE = {
|
||||
"deposit": "Einzahlung",
|
||||
"withdrawal": "Auszahlung",
|
||||
"expense": "Auslage",
|
||||
"product": "Einkauf",
|
||||
"order": "Einkauf",
|
||||
}
|
||||
|
||||
UNITS_OF_MEASURE = {"piece": "Stück"}
|
||||
|
||||
@@ -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(
|
||||
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
31
test/fake_data.py
Normal 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)
|
||||
@@ -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
71
test/test_shop.py
Normal 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
25
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user