Compare commits
2 Commits
f4618f4d05
...
fd544fcebc
| Author | SHA1 | Date | |
|---|---|---|---|
| fd544fcebc | |||
| 00246819cc |
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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] %}
|
{% 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>
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
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
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"
|
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
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]
|
[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"
|
||||||
|
|||||||
Reference in New Issue
Block a user