Implement shopping cart

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

View File

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

View File

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

View File

@@ -1,8 +1,12 @@
from decimal import Decimal
from fastapi import APIRouter, Request
from sqlalchemy import select
from starlette import status
from starlette.responses import RedirectResponse
from allmende_payment_system.api import SessionDep
from allmende_payment_system.models import Area
from allmende_payment_system.api.dependencies import SessionDep, UserDep
from allmende_payment_system.models import Area, OrderItem, Product
from allmende_payment_system.tools import get_jinja_renderer
shop_router = APIRouter()
@@ -20,6 +24,26 @@ async def get_shop(request: Request, session: SessionDep):
)
@shop_router.get("/shop/cart")
async def get_cart(request: Request, session: SessionDep, user: UserDep):
return templates.TemplateResponse(
"cart.html.jinja",
context={"request": request, "user": user},
)
@shop_router.get("/shop/finalize_order")
async def get_cart(request: Request, session: SessionDep, user: UserDep):
cart = user.shopping_cart
# TODO: Implement
cart.finalize_order()
return RedirectResponse(url=f"/", status_code=status.HTTP_302_FOUND)
@shop_router.get("/shop/area/{area_id}")
async def get_shop(request: Request, session: SessionDep, area_id: int):
query = select(Area).where(Area.id == area_id)
@@ -28,3 +52,27 @@ async def get_shop(request: Request, session: SessionDep, area_id: int):
"area.html.jinja",
context={"request": request, "area": area},
)
@shop_router.post("/shop/cart/add")
async def add_to_cart(request: Request, session: SessionDep, user: UserDep):
form_data = await request.form()
query = select(Product).where(Product.id == form_data["product_id"])
product = session.scalars(query).one()
quantity = Decimal(form_data["quantity"])
total_amount = product.price * quantity
order_item = OrderItem(
product=product, quantity=quantity, total_amount=total_amount
)
session.add(order_item)
cart = user.shopping_cart
cart.items.append(order_item)
session.flush()
return RedirectResponse(
url=f"/shop/area/{form_data['area_id']}", status_code=status.HTTP_302_FOUND
)

View File

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

View File

@@ -82,6 +82,7 @@ class Area(Base):
UnitsOfMeasure = typing.Literal[
"g",
"kg",
"l",
"piece",
]
@@ -121,6 +122,10 @@ class Order(Base):
def is_in_shopping_cart(self):
return self.account is None
@property
def total_amount(self):
return sum(item.total_amount for item in self.items)
class OrderItem(Base):
__tablename__ = TABLE_PREFIX + "order_item"
@@ -130,7 +135,7 @@ 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
)

View File

@@ -25,12 +25,85 @@
<p class="card-text text-muted small mb-3">{{ product.description }}</p>
<div class="d-flex justify-content-between align-items-center">
<span class="fw-bold">{{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }}</span>
<button class="btn btn-sm btn-outline-primary">Add to Cart</button>
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#productModal{{ product.id }}">
In den Warenkorb
</button>
</div>
</div>
</a>
</div>
</div>
<!-- Product Modal -->
<div class="modal fade" id="productModal{{ product.id }}" tabindex="-1" aria-labelledby="productModalLabel{{ product.id }}" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<form method="POST" action="/shop/cart/add">
<div class="modal-header">
<h5 class="modal-title" id="productModalLabel{{ product.id }}">{{ product.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Product Image -->
<img
src="/static/img/{{ product.image_path if product.image_path else 'placeholder.jpg' }}"
alt="{{ product.name }}"
class="img-fluid rounded mb-3"
style="max-height: 200px; width: 100%; object-fit: cover;"
>
<!-- Product Description -->
<p class="text-muted mb-3">{{ product.description }}</p>
<!-- Price -->
<div class="mb-3">
<h6 class="fw-bold">Preis: {{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }}</h6>
</div>
<!-- Quantity Selector -->
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="area_id" value="{{ area.id }}">
<div class="mb-3">
<label for="quantity{{ product.id }}" class="form-label">Menge</label>
<input type="number"
class="form-control"
id="quantity{{ product.id }}"
name="quantity"
value="1"
min="1"
max="999"
oninput="updateTotal{{ product.id }}(this.value)"
style="max-width: 150px;">
</div>
<!-- Total Price -->
<div class="mb-3">
<h6>Gesamt: <span id="total{{ product.id }}" class="fw-bold">{{ '%.2f' | format(product.price) }} €</span></h6>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="submit" class="btn btn-primary">
In den Warenkorb legen
</button>
</div>
</form>
</div>
</div>
</div>
{# TODO: Make this a global function #}
<script>
function updateTotal{{ product.id }}(quantity) {
const price = {{ product.price }};
const total = (price * quantity).toFixed(2);
document.getElementById('total{{ product.id }}').textContent = total + ' €';
}
</script>
{% endfor %}
</div>
{% endblock %}

View File

@@ -29,21 +29,21 @@
Einkaufen
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link">
Lorem
</a>
</li>
<li class="nav-item">
<a href="#" class="nav-link">
Ipsum
</a>
</li>
{# <li class="nav-item">#}
{# <a href="#" class="nav-link">#}
{# Lorem#}
{# </a>#}
{# </li>#}
{# <li class="nav-item">#}
{# <a href="#" class="nav-link">#}
{# Ipsum#}
{# </a>#}
{# </li>#}
</ul>
<!-- Shopping Cart at Bottom -->
<div class="mt-auto pt-3 border-top">
<a href="/cart" class="btn btn-primary w-100 position-relative">
<a href="/shop/cart" class="btn btn-primary w-100 position-relative">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="bi bi-cart3 me-2" viewBox="0 0 16 16">
<path d="M0 1.5A.5.5 0 0 1 .5 1H2a.5.5 0 0 1 .485.379L2.89 3H14.5a.5.5 0 0 1 .49.598l-1 5a.5.5 0 0 1-.465.401l-9.397.472L4.415 11H13a.5.5 0 0 1 0 1H4a.5.5 0 0 1-.491-.408L2.01 3.607 1.61 2H.5a.5.5 0 0 1-.5-.5M3.102 4l.84 4.479 9.144-.459L13.89 4zM5 12a2 2 0 1 0 0 4 2 2 0 0 0 0-4m7 0a2 2 0 1 0 0 4 2 2 0 0 0 0-4m-7 1a1 1 0 1 1 0 2 1 1 0 0 1 0-2m7 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2"/>
</svg>

View File

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