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

@@ -0,0 +1,112 @@
"""introduce product details
Revision ID: 3b61a6fbc6b2
Revises: 958d7aee2b21
Create Date: 2026-04-22 11:56:31.223186
"""
import datetime
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "3b61a6fbc6b2"
down_revision: Union[str, Sequence[str], None] = "958d7aee2b21"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"aps_product_detail",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("product_id", sa.Integer(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("price", sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column(
"unit_of_measure",
sa.Enum("g", "kg", "l", "piece", native_enum=False),
nullable=False,
),
sa.Column("allow_fractional", sa.Boolean(), nullable=False),
sa.Column("temporarily_unavailable", sa.Boolean(), nullable=False),
sa.Column("vat_rate", sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column("valid_from", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(
["product_id"],
["aps_product.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
# Step 2: Copy data from aps_product to aps_product_detail
# Bind the current connection to a temporary session
conn = op.get_bind()
# Fetch all products
products = conn.execute(
sa.text(
"SELECT id, name, price, unit_of_measure, allow_fractional, vat_rate FROM aps_product"
)
).fetchall()
# Insert data into aps_product_detail
for product in products:
conn.execute(
sa.text(
"""
INSERT INTO aps_product_detail
(name, product_id, description, price, unit_of_measure, allow_fractional, temporarily_unavailable, vat_rate, valid_from)
VALUES (:name, :product_id, :description, :price, :unit_of_measure, :allow_fractional, :temporarily_unavailable, :vat_rate, :valid_from)
"""
),
{
"name": product.name,
"product_id": product.id,
"description": "",
"price": product.price,
"unit_of_measure": product.unit_of_measure,
"allow_fractional": product.allow_fractional,
"temporarily_unavailable": False,
"vat_rate": product.vat_rate,
"valid_from": datetime.datetime(1970, 1, 1, 0, 0, 0),
},
)
with op.batch_alter_table("aps_product") as batch_op:
batch_op.drop_column("vat_rate")
batch_op.drop_column("name")
batch_op.drop_column("allow_fractional")
batch_op.drop_column("unit_of_measure")
batch_op.drop_column("price")
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"aps_product",
sa.Column("price", sa.NUMERIC(precision=10, scale=2), nullable=False),
)
op.add_column(
"aps_product",
sa.Column("unit_of_measure", sa.VARCHAR(length=5), nullable=False),
)
op.add_column(
"aps_product", sa.Column("allow_fractional", sa.BOOLEAN(), nullable=False)
)
op.add_column(
"aps_product",
sa.Column("vat_rate", sa.NUMERIC(precision=10, scale=2), nullable=False),
)
op.add_column("aps_product", sa.Column("name", sa.VARCHAR(), nullable=False))
op.drop_table("aps_product_detail")
# ### end Alembic commands ###

View File

@@ -5,6 +5,7 @@ Revises:
Create Date: 2026-01-23 10:10:36.825333
"""
from typing import Sequence, Union
from alembic import op
@@ -12,7 +13,7 @@ import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '958d7aee2b21'
revision: str = "958d7aee2b21"
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
@@ -21,96 +22,148 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('aps_account',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
op.create_table(
"aps_account",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table('aps_area',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('image_path', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
op.create_table(
"aps_area",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("image_path", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table('aps_user',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('display_name', sa.String(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('username')
op.create_table(
"aps_user",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("username", sa.String(), nullable=False),
sa.Column("display_name", sa.String(), nullable=False),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username"),
)
op.create_table('aps_user_group',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
op.create_table(
"aps_user_group",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table('aps_order',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"aps_order",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["user_id"],
["aps_user.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('aps_permission',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('scope', sa.String(), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('user_group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_group_id'], ['aps_user_group.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"aps_permission",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("scope", sa.String(), nullable=False),
sa.Column("action", sa.String(), nullable=False),
sa.Column("user_group_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["user_group_id"],
["aps_user_group.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('aps_product',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('price', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('unit_of_measure', sa.Enum('g', 'kg', 'l', 'piece', native_enum=False), nullable=False),
sa.Column('allow_fractional', sa.Boolean(), nullable=False),
sa.Column('vat_rate', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('area_id', sa.Integer(), nullable=False),
sa.Column('image_path', sa.String(), nullable=True),
sa.ForeignKeyConstraint(['area_id'], ['aps_area.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('name')
op.create_table(
"aps_product",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("price", sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column(
"unit_of_measure",
sa.Enum("g", "kg", "l", "piece", native_enum=False),
nullable=False,
),
sa.Column("allow_fractional", sa.Boolean(), nullable=False),
sa.Column("vat_rate", sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column("area_id", sa.Integer(), nullable=False),
sa.Column("image_path", sa.String(), nullable=True),
sa.ForeignKeyConstraint(
["area_id"],
["aps_area.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
)
op.create_table('aps_user_account_association',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['aps_account.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'account_id')
op.create_table(
"aps_user_account_association",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["aps_account.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["aps_user.id"],
),
sa.PrimaryKeyConstraint("user_id", "account_id"),
)
op.create_table('aps_user_user_group_association',
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('user_group_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['user_group_id'], ['aps_user_group.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['aps_user.id'], ),
sa.PrimaryKeyConstraint('user_id', 'user_group_id')
op.create_table(
"aps_user_user_group_association",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("user_group_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["user_group_id"],
["aps_user_group.id"],
),
sa.ForeignKeyConstraint(
["user_id"],
["aps_user.id"],
),
sa.PrimaryKeyConstraint("user_id", "user_group_id"),
)
op.create_table('aps_order_item',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('order_id', sa.Integer(), nullable=False),
sa.Column('product_id', sa.Integer(), nullable=False),
sa.Column('quantity', sa.Numeric(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(['order_id'], ['aps_order.id'], ),
sa.ForeignKeyConstraint(['product_id'], ['aps_product.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"aps_order_item",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("order_id", sa.Integer(), nullable=False),
sa.Column("product_id", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Numeric(), nullable=False),
sa.Column("total_amount", sa.Numeric(precision=10, scale=2), nullable=False),
sa.ForeignKeyConstraint(
["order_id"],
["aps_order.id"],
),
sa.ForeignKeyConstraint(
["product_id"],
["aps_product.id"],
),
sa.PrimaryKeyConstraint("id"),
)
op.create_table('aps_transaction',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('type', sa.Enum('order', 'deposit', 'withdrawal', 'expense', native_enum=False), nullable=False),
sa.Column('quantity', sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column('timestamp', sa.DateTime(), nullable=False),
sa.Column('total_amount', sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column('order_id', sa.Integer(), nullable=True),
sa.Column('account_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['aps_account.id'], ),
sa.ForeignKeyConstraint(['order_id'], ['aps_order.id'], ),
sa.PrimaryKeyConstraint('id')
op.create_table(
"aps_transaction",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column(
"type",
sa.Enum("order", "deposit", "withdrawal", "expense", native_enum=False),
nullable=False,
),
sa.Column("quantity", sa.Numeric(precision=10, scale=2), nullable=True),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("total_amount", sa.Numeric(precision=10, scale=2), nullable=False),
sa.Column("order_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(
["account_id"],
["aps_account.id"],
),
sa.ForeignKeyConstraint(
["order_id"],
["aps_order.id"],
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
@@ -118,15 +171,15 @@ def upgrade() -> None:
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('aps_transaction')
op.drop_table('aps_order_item')
op.drop_table('aps_user_user_group_association')
op.drop_table('aps_user_account_association')
op.drop_table('aps_product')
op.drop_table('aps_permission')
op.drop_table('aps_order')
op.drop_table('aps_user_group')
op.drop_table('aps_user')
op.drop_table('aps_area')
op.drop_table('aps_account')
op.drop_table("aps_transaction")
op.drop_table("aps_order_item")
op.drop_table("aps_user_user_group_association")
op.drop_table("aps_user_account_association")
op.drop_table("aps_product")
op.drop_table("aps_permission")
op.drop_table("aps_order")
op.drop_table("aps_user_group")
op.drop_table("aps_user")
op.drop_table("aps_area")
op.drop_table("aps_account")
# ### end Alembic commands ###

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>

View File

@@ -12,6 +12,7 @@ from allmende_payment_system.models import (
Area,
Permission,
Product,
ProductDetails,
User,
UserGroup,
)
@@ -219,9 +220,49 @@ def test_add_product(test_db, client, admin_user):
follow_redirects=False,
)
assert response.status_code == 303
product = test_db.execute(
select(Product).where(Product.name == "Test Product")
).scalar()
product = test_db.execute(select(Product)).scalar()
assert product is not None
assert product.name == "Test Product"
assert product.price == Decimal("9.99")
assert len(product.details_history) == 1
assert product.current_details.name == "Test Product"
assert product.current_details.price == Decimal("9.99")
def test_change_product(test_db, client, admin_user):
area = Area(name="Test Area", description="An area for testing")
test_db.add(area)
product = Product(
area=area,
)
product.details_history.append(
ProductDetails(
name="Test Product",
price=Decimal("9.99"),
unit_of_measure="piece",
vat_rate=Decimal("19.00"),
)
)
test_db.add(product)
test_db.flush()
response = client.post(
f"/admin/products/edit/{product.id}",
data={
"name": "Updated Product",
"vat_rate": "7.00",
"unit_of_measure": "kg",
"price": 19.99,
"area_id": area.id,
},
user=admin_user,
follow_redirects=False,
)
assert response.status_code == 303
updated_product = test_db.execute(
select(Product).where(Product.id == product.id)
).scalar()
assert len(updated_product.details_history) == 2
assert updated_product.current_details.name == "Updated Product"
assert updated_product.current_details.price == Decimal("19.99")
assert updated_product.current_details.price == Decimal("19.99")

View File

@@ -32,9 +32,9 @@ def create_user_with_account(test_db, username: str, balance: float | None = Non
return user
def add_finalized_order_to_user(test_db, user, product) -> Order:
def add_finalized_order_to_user(test_db, user, product: Product) -> Order:
order = Order(user=user)
total_amount = product.price
total_amount = product.current_details.price
order.items.append(
OrderItem(product=product, quantity=1, total_amount=total_amount)
)
@@ -49,7 +49,7 @@ def add_finalized_order_to_user(test_db, user, product) -> Order:
def product(test_db):
area = Area(**fake.area())
test_db.add(area)
product = Product(**fake.product())
product = Product.create(**fake.product())
product.area = area
test_db.add(product)
test_db.flush()
@@ -96,15 +96,17 @@ def test_edit_item_in_cart(client: APSTestClient, test_db, product):
cart = test_db.scalar(select(Order))
assert len(cart.items) == 1
assert cart.items[0].quantity == 3
assert cart.items[0].total_amount == product.price * 3
assert cart.items[0].total_amount == product.current_details.price * 3
def test_remove_item_from_cart(client: APSTestClient, test_db, product):
def test_remove_item_from_cart(client: APSTestClient, test_db, product: Product):
user = create_user_with_account(test_db, "test")
user.shopping_cart.items.append(
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
OrderItem(
product=product, quantity=2, total_amount=product.current_details.price * 2
)
)
test_db.flush()
@@ -122,11 +124,15 @@ def test_remove_item_from_cart(client: APSTestClient, test_db, product):
assert len(test_db.scalars(select(OrderItem)).all()) == 0
def test_remove_item_from_cart_wrong_user(client: APSTestClient, test_db, product):
def test_remove_item_from_cart_wrong_user(
client: APSTestClient, test_db, product: Product
):
user = create_user_with_account(test_db, "test")
user.shopping_cart.items.append(
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
OrderItem(
product=product, quantity=2, total_amount=product.current_details.price * 2
)
)
test_db.flush()
@@ -144,7 +150,7 @@ def test_remove_item_from_cart_wrong_user(client: APSTestClient, test_db, produc
assert len(cart.items) == 1
def test_finalize_order(client: APSTestClient, test_db, product):
def test_finalize_order(client: APSTestClient, test_db, product: Product):
user = create_user_with_account(test_db, "test", balance=100.0)
second_account = Account(name=f"Another account")
@@ -155,7 +161,9 @@ def test_finalize_order(client: APSTestClient, test_db, product):
user.accounts.append(second_account)
user.shopping_cart.items.append(
OrderItem(product=product, quantity=2, total_amount=product.price * 2)
OrderItem(
product=product, quantity=2, total_amount=product.current_details.price * 2
)
)
test_db.flush()
@@ -170,10 +178,12 @@ def test_finalize_order(client: APSTestClient, test_db, product):
assert len(user.orders) == 2 # shopping cart + finalized order
assert user.accounts[0].balance == Decimal(100.0)
assert user.accounts[1].balance == Decimal(50.0) - (product.price * 2)
assert user.accounts[1].balance == Decimal(50.0) - (
product.current_details.price * 2
)
def test_view_order(client: APSTestClient, test_db, product):
def test_view_order(client: APSTestClient, test_db, product: Product):
user = create_user_with_account(test_db, "test")
order = add_finalized_order_to_user(test_db, user, product)
@@ -181,10 +191,10 @@ def test_view_order(client: APSTestClient, test_db, product):
response = client.get(f"/shop/order/{order.id}")
assert response.status_code == 200
assert f"Einkauf #{order.id}" in response.text
assert product.name in response.text
assert product.current_details.name in response.text
def test_view_order_wrong_user(client: APSTestClient, test_db, product):
def test_view_order_wrong_user(client: APSTestClient, test_db, product: Product):
user = create_user_with_account(test_db, "test")
order = add_finalized_order_to_user(test_db, user, product)