Introduce product details table

This commit is contained in:
2026-04-22 12:25:35 +02:00
parent 6e6636e941
commit e8266ffdc1
11 changed files with 416 additions and 168 deletions

View File

@@ -1,7 +1,8 @@
import datetime
from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Request
from sqlalchemy import select
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from starlette import status
from starlette.responses import RedirectResponse
@@ -13,6 +14,7 @@ from allmende_payment_system.models import (
Area,
Permission,
Product,
ProductDetails,
Transaction,
User,
UserGroup,
@@ -234,9 +236,14 @@ async def edit_product_post(
select(Product).where(Product.id == product_id)
).scalar_one()
for field_name, data in product_data.model_dump().items():
setattr(product, field_name, data)
new_details = ProductDetails(
valid_from=func.now(),
)
for field_name, data in product_data.model_dump().items():
setattr(new_details, field_name, data)
product.details_history.append(new_details)
session.flush()
return RedirectResponse(
@@ -273,12 +280,15 @@ async def new_product_post(
if not user.has_permission("product", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions")
product = Product()
product = Product(area_id=product_data.area_id)
session.add(product)
session.flush()
product_details = ProductDetails()
product.details_history.append(product_details)
for field_name, data in product_data.model_dump().items():
setattr(product, field_name, data)
session.add(product)
setattr(product_details, field_name, data)
return RedirectResponse(
url="/admin/products", status_code=status.HTTP_303_SEE_OTHER

View File

@@ -78,7 +78,7 @@ async def add_to_cart(request: Request, session: SessionDep, user: UserDep):
product = session.scalars(query).one()
quantity = Decimal(form_data["quantity"])
total_amount = product.price * quantity
total_amount = product.current_details.price * quantity
order_item = OrderItem(
product=product, quantity=quantity, total_amount=total_amount

View File

@@ -2,7 +2,7 @@ import datetime
import decimal
import typing
from sqlalchemy import Column, ForeignKey, Numeric, Table, select
from sqlalchemy import Column, ForeignKey, Numeric, Table, func, select
from sqlalchemy.exc import NoResultFound
from sqlalchemy.orm import (
DeclarativeBase,
@@ -135,23 +135,59 @@ class Area(Base):
products: Mapped[list["Product"]] = relationship("Product")
class Product(Base):
__tablename__ = TABLE_PREFIX + "product"
class ProductDetails(Base):
"""Describes details of the product that can change over time like price, unit of measure, vat rate, etc."""
__tablename__ = TABLE_PREFIX + "product_detail"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=True)
product_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "product.id"))
product: Mapped["Product"] = relationship("Product")
description: Mapped[str] = mapped_column(nullable=True)
price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False)
allow_fractional: Mapped[bool] = mapped_column(nullable=False, default=True)
temporarily_unavailable: Mapped[bool] = mapped_column(nullable=False, default=False)
# TODO: limit this to actually used vat rates?
vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
valid_from: Mapped[datetime.datetime] = mapped_column(
nullable=False, default=datetime.datetime(1970, 1, 1, 0, 0, 0)
)
class Product(Base):
__tablename__ = TABLE_PREFIX + "product"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
area_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "area.id"))
area: Mapped["Area"] = relationship("Area", back_populates="products")
image_path: Mapped[str] = mapped_column(nullable=True)
details_history: Mapped[list["ProductDetails"]] = relationship(
"ProductDetails", cascade="all, delete-orphan", back_populates="product"
)
current_details: Mapped["ProductDetails"] = relationship(
"ProductDetails",
primaryjoin="and_(Product.id == ProductDetails.product_id, ProductDetails.valid_from <= func.now())",
order_by="ProductDetails.valid_from.desc()",
uselist=False,
viewonly=True,
)
@classmethod
def create(cls, area_id: int | None = None, **kwargs):
details = ProductDetails(**kwargs, valid_from=func.now())
product = cls(area_id=area_id)
product.details_history.append(details)
return product
class Order(Base):
__tablename__ = TABLE_PREFIX + "order"
@@ -217,7 +253,7 @@ class OrderItem(Base):
raise ValueError("Quantity must be positive.")
self.quantity = new_quantity
self.total_amount = self.product.price * new_quantity
self.total_amount = self.product.current_details.price * new_quantity
TransactionTypes = typing.Literal[

View File

@@ -12,19 +12,12 @@
<div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm">
<a href="#" class="text-decoration-none text-dark">
{#<!-- Product Image -->
<img
src="/static/img/{{ product.image_path if product.image_path else 'placeholder.jpg' }}"
alt="{{ product.name }}"
class="card-img-top img-fluid rounded-top"
style="height: 100px; object-fit: cover;"
>#}
<!-- Product Details -->
<div class="card-body">
<h5 class="card-title mb-2">{{ product.name }}</h5>
<p class="card-text text-muted small mb-3">{{ product.description }}</p>
<h5 class="card-title mb-2">{{ product.current_details.name }}</h5>
<p class="card-text text-muted small mb-3">{{ product.current_details.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>
<span class="fw-bold">{{ product.current_details.price|format_number }} € pro {{ product.current_details.unit_of_measure|units_of_measure_de }}</span>
<button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal"
data-bs-target="#productModal{{ product.id }}">
@@ -43,45 +36,38 @@
<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>
<h5 class="modal-title" id="productModalLabel{{ product.id }}">{{ product.current_details.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>
<p class="text-muted mb-3">{{ product.current_details.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>
<h6 class="fw-bold">Preis: {{ product.current_details.price|format_number }} € pro {{ product.current_details.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{% if product.unit_of_measure != 'piece' %} (in {{product.unit_of_measure}}){% endif %}</label>
<label for="quantity{{ product.id }}" class="form-label">Menge{% if product.current_details.unit_of_measure != 'piece' %} (in {{product.current_details.unit_of_measure}}){% endif %}</label>
<input type="number"
class="form-control"
id="quantity{{ product.id }}"
name="quantity"
value="1"
{% if product.allow_fractional %}min="0.01"{% else %}min="1"{% endif %}
{% if product.current_details.allow_fractional %}min="0.01"{% else %}min="1"{% endif %}
max="999"
{% if product.allow_fractional %}step="0.01"{% else %}step="1"{% endif %}
{% if product.current_details.allow_fractional %}step="0.01"{% else %}step="1"{% endif %}
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>
<h6>Gesamt: <span id="total{{ product.id }}" class="fw-bold">{{ '%.2f' | format(product.current_details.price) }} €</span></h6>
</div>
</div>
@@ -98,7 +84,7 @@
{# TODO: Make this a global function #}
<script>
function updateTotal{{ product.id }}(quantity) {
const price = {{ product.price }};
const price = {{ product.current_details.price }};
const total = (price * quantity).toFixed(2);
document.getElementById('total{{ product.id }}').textContent = total + ' €';
}

View File

@@ -29,7 +29,7 @@
{% set total.value = total.value + subtotal %}
<tr>
<td>
<div class="fw-semibold">{{ item.product.name }}</div>
<div class="fw-semibold">{{ item.product.current_details.name }}</div>
<div class="text-muted small">{{ item.description or '' }}</div>
</td>
<td class="text-center" style="width:180px;">
@@ -38,19 +38,19 @@
<input
type="number"
name="quantity"
value="{% if item.product.allow_fractional %}{{ item.quantity | format_number }}{% else %}{{ item.quantity | int }}{% endif %}"
{% if item.product.allow_fractional %}min="0.01" step="0.01"{% else %}min="1" step="1"{% endif %}
value="{% if item.product.current_details.allow_fractional %}{{ item.quantity | format_number }}{% else %}{{ item.quantity | int }}{% endif %}"
{% if item.product.current_details.allow_fractional %}min="0.01" step="0.01"{% else %}min="1" step="1"{% endif %}
class="form-control form-control-sm me-2"
style="width:80px;"
required>
{% if item.product.unit_of_measure != 'piece' %}<span class="text-muted small ms-1 me-2">{{ item.product.unit_of_measure }}</span>{% endif %}
{% if item.product.current_details.unit_of_measure != 'piece' %}<span class="text-muted small ms-1 me-2">{{ item.product.unit_of_measure }}</span>{% endif %}
<button class="btn btn-sm btn-outline-secondary" type="submit">Aktualisieren</button>
</form>
{% else %}
{{ item.quantity | format_number }}{% if item.product.unit_of_measure != 'piece' %} {{ item.product.unit_of_measure }}{% endif %}
{{ item.quantity | format_number }}{% if item.product.current_details.unit_of_measure != 'piece' %} {{ item.product.unit_of_measure }}{% endif %}
{% endif %}
</td>
<td class="text-end">{{ item.product.price | format_number }} €</td>
<td class="text-end">{{ item.product.current_details.price | format_number }} €</td>
<td class="text-end">{{ item.total_amount | format_number }} €</td>
<td class="text-end">
{% if is_cart %}<a href="/shop/cart/remove/{{ item.id }}" class="btn btn-sm btn-outline-danger">Entfernen</a>{% endif %}

View File

@@ -7,34 +7,34 @@
<form method="post">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" {% if edit_mode %}value="{{ product.name }}"{% endif %} required>
<input type="text" class="form-control" id="name" name="name" {% if edit_mode %}value="{{ product.current_details.name }}"{% endif %} required>
</div>
<div class="mb-3">
<label for="price" class="form-label">Preis</label>
<input type="number" step="0.01" class="form-control" id="price" name="price" {% if edit_mode %}value="{{ product.price }}"{% endif %} required>
<input type="number" step="0.01" class="form-control" id="price" name="price" {% if edit_mode %}value="{{ product.current_details.price }}"{% endif %} required>
</div>
<div class="mb-3">
<label for="unit_of_measure" class="form-label">Einheit</label>
<select class="form-select" id="unit_of_measure" name="unit_of_measure" required>
<option value="g" {% if edit_mode and product.unit_of_measure == 'g' %}selected{% endif %}>g</option>
<option value="kg" {% if edit_mode and product.unit_of_measure == 'kg' %}selected{% endif %}>kg</option>
<option value="l" {% if edit_mode and product.unit_of_measure == 'l' %}selected{% endif %}>l</option>
<option value="piece" {% if edit_mode and product.unit_of_measure == 'piece' %}selected{% endif %}>Stück</option>
<option value="g" {% if edit_mode and product.current_details.unit_of_measure == 'g' %}selected{% endif %}>g</option>
<option value="kg" {% if edit_mode and product.current_details.unit_of_measure == 'kg' %}selected{% endif %}>kg</option>
<option value="l" {% if edit_mode and product.current_details.unit_of_measure == 'l' %}selected{% endif %}>l</option>
<option value="piece" {% if edit_mode and product.current_details.unit_of_measure == 'piece' %}selected{% endif %}>Stück</option>
</select>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="allow_fractional" name="allow_fractional" {% if edit_mode and product.allow_fractional %}checked{% endif %}>
<input type="checkbox" class="form-check-input" id="allow_fractional" name="allow_fractional" {% if edit_mode and product.current_details.allow_fractional %}checked{% endif %}>
<label class="form-check-label" for="allow_fractional">Bruchteile erlauben</label>
</div>
<div class="mb-3">
<label for="vat_rate" class="form-label">MwSt-Satz</label>
<select class="form-select" id="vat_rate" name="vat_rate" required>
<option value="7" {% if edit_mode and product.vat_rate == '7' %}selected{% endif %}>7 %</option>
<option value="19" {% if edit_mode and product.vat_rate == '19' %}selected{% endif %}>19 %</option>
<option value="7" {% if edit_mode and product.current_details.vat_rate == '7' %}selected{% endif %}>7 %</option>
<option value="19" {% if edit_mode and product.current_details.vat_rate == '19' %}selected{% endif %}>19 %</option>
</select>
</div>

View File

@@ -25,11 +25,11 @@
{% for product in products %}
<tr>
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.current_details.name }}</td>
<td>{{ product.area.name }}</td>
<td>{{ product.price | format_number}} €</td>
<td>{{ product.unit_of_measure | units_of_measure_de}}</td>
<td>{{ product.vat_rate | int }} %</td>
<td>{{ product.current_details.price | format_number}} €</td>
<td>{{ product.current_details.unit_of_measure | units_of_measure_de}}</td>
<td>{{ product.current_details.vat_rate | int }} %</td>
<td class="text-end">
<a class="btn btn-sm btn-primary" href="/admin/products/edit/{{ product.id }}">Bearbeiten</a>
</td>