Implement shopping cart
This commit is contained in:
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user