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

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

View File

@@ -1,7 +1,8 @@
import datetime
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, File, Form, HTTPException, Request from fastapi import APIRouter, File, Form, HTTPException, Request
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from starlette import status from starlette import status
from starlette.responses import RedirectResponse from starlette.responses import RedirectResponse
@@ -13,6 +14,7 @@ from allmende_payment_system.models import (
Area, Area,
Permission, Permission,
Product, Product,
ProductDetails,
Transaction, Transaction,
User, User,
UserGroup, UserGroup,
@@ -234,9 +236,14 @@ async def edit_product_post(
select(Product).where(Product.id == product_id) select(Product).where(Product.id == product_id)
).scalar_one() ).scalar_one()
for field_name, data in product_data.model_dump().items(): new_details = ProductDetails(
setattr(product, field_name, data) 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() session.flush()
return RedirectResponse( return RedirectResponse(
@@ -273,12 +280,15 @@ async def new_product_post(
if not user.has_permission("product", "edit"): if not user.has_permission("product", "edit"):
raise HTTPException(status_code=403, detail="Insufficient permissions") 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(): for field_name, data in product_data.model_dump().items():
setattr(product, field_name, data) setattr(product_details, field_name, data)
session.add(product)
return RedirectResponse( return RedirectResponse(
url="/admin/products", status_code=status.HTTP_303_SEE_OTHER 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() product = session.scalars(query).one()
quantity = Decimal(form_data["quantity"]) quantity = Decimal(form_data["quantity"])
total_amount = product.price * quantity total_amount = product.current_details.price * quantity
order_item = OrderItem( order_item = OrderItem(
product=product, quantity=quantity, total_amount=total_amount product=product, quantity=quantity, total_amount=total_amount

View File

@@ -2,7 +2,7 @@ import datetime
import decimal import decimal
import typing 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.exc import NoResultFound
from sqlalchemy.orm import ( from sqlalchemy.orm import (
DeclarativeBase, DeclarativeBase,
@@ -135,23 +135,59 @@ class Area(Base):
products: Mapped[list["Product"]] = relationship("Product") products: Mapped[list["Product"]] = relationship("Product")
class Product(Base): class ProductDetails(Base):
__tablename__ = TABLE_PREFIX + "product" """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) id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(nullable=False, unique=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)) price: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2))
unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False) unit_of_measure: Mapped[UnitsOfMeasure] = mapped_column(nullable=False)
allow_fractional: Mapped[bool] = mapped_column(nullable=False, default=True) 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? # TODO: limit this to actually used vat rates?
vat_rate: Mapped[decimal.Decimal] = mapped_column(Numeric(10, 2)) 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_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "area.id"))
area: Mapped["Area"] = relationship("Area", back_populates="products") area: Mapped["Area"] = relationship("Area", back_populates="products")
image_path: Mapped[str] = mapped_column(nullable=True) 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): class Order(Base):
__tablename__ = TABLE_PREFIX + "order" __tablename__ = TABLE_PREFIX + "order"
@@ -217,7 +253,7 @@ class OrderItem(Base):
raise ValueError("Quantity must be positive.") raise ValueError("Quantity must be positive.")
self.quantity = new_quantity 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[ TransactionTypes = typing.Literal[

View File

@@ -12,19 +12,12 @@
<div class="col-md-6 col-lg-4"> <div class="col-md-6 col-lg-4">
<div class="card h-100 border-0 shadow-sm"> <div class="card h-100 border-0 shadow-sm">
<a href="#" class="text-decoration-none text-dark"> <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 --> <!-- Product Details -->
<div class="card-body"> <div class="card-body">
<h5 class="card-title mb-2">{{ product.name }}</h5> <h5 class="card-title mb-2">{{ product.current_details.name }}</h5>
<p class="card-text text-muted small mb-3">{{ product.description }}</p> <p class="card-text text-muted small mb-3">{{ product.current_details.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.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" <button class="btn btn-sm btn-outline-primary"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#productModal{{ product.id }}"> data-bs-target="#productModal{{ product.id }}">
@@ -43,45 +36,38 @@
<div class="modal-content"> <div class="modal-content">
<form method="POST" action="/shop/cart/add"> <form method="POST" action="/shop/cart/add">
<div class="modal-header"> <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> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <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 --> <!-- Product Description -->
<p class="text-muted mb-3">{{ product.description }}</p> <p class="text-muted mb-3">{{ product.current_details.description }}</p>
<!-- Price --> <!-- Price -->
<div class="mb-3"> <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> </div>
<!-- Quantity Selector --> <!-- Quantity Selector -->
<input type="hidden" name="product_id" value="{{ product.id }}"> <input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="area_id" value="{{ area.id }}"> <input type="hidden" name="area_id" value="{{ area.id }}">
<div class="mb-3"> <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" <input type="number"
class="form-control" class="form-control"
id="quantity{{ product.id }}" id="quantity{{ product.id }}"
name="quantity" name="quantity"
value="1" 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" 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)" oninput="updateTotal{{ product.id }}(this.value)"
style="max-width: 150px;"> style="max-width: 150px;">
</div> </div>
<!-- Total Price --> <!-- Total Price -->
<div class="mb-3"> <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>
</div> </div>
@@ -98,7 +84,7 @@
{# TODO: Make this a global function #} {# TODO: Make this a global function #}
<script> <script>
function updateTotal{{ product.id }}(quantity) { function updateTotal{{ product.id }}(quantity) {
const price = {{ product.price }}; const price = {{ product.current_details.price }};
const total = (price * quantity).toFixed(2); const total = (price * quantity).toFixed(2);
document.getElementById('total{{ product.id }}').textContent = total + ' €'; document.getElementById('total{{ product.id }}').textContent = total + ' €';
} }

View File

@@ -29,7 +29,7 @@
{% set total.value = total.value + subtotal %} {% set total.value = total.value + subtotal %}
<tr> <tr>
<td> <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> <div class="text-muted small">{{ item.description or '' }}</div>
</td> </td>
<td class="text-center" style="width:180px;"> <td class="text-center" style="width:180px;">
@@ -38,19 +38,19 @@
<input <input
type="number" type="number"
name="quantity" name="quantity"
value="{% if item.product.allow_fractional %}{{ item.quantity | format_number }}{% else %}{{ item.quantity | int }}{% endif %}" value="{% if item.product.current_details.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 %} {% 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" class="form-control form-control-sm me-2"
style="width:80px;" style="width:80px;"
required> 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> <button class="btn btn-sm btn-outline-secondary" type="submit">Aktualisieren</button>
</form> </form>
{% else %} {% 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 %} {% endif %}
</td> </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">{{ item.total_amount | format_number }} €</td>
<td class="text-end"> <td class="text-end">
{% if is_cart %}<a href="/shop/cart/remove/{{ item.id }}" class="btn btn-sm btn-outline-danger">Entfernen</a>{% endif %} {% 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"> <form method="post">
<div class="mb-3"> <div class="mb-3">
<label for="name" class="form-label">Name</label> <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>
<div class="mb-3"> <div class="mb-3">
<label for="price" class="form-label">Preis</label> <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>
<div class="mb-3"> <div class="mb-3">
<label for="unit_of_measure" class="form-label">Einheit</label> <label for="unit_of_measure" class="form-label">Einheit</label>
<select class="form-select" id="unit_of_measure" name="unit_of_measure" required> <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="g" {% if edit_mode and product.current_details.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="kg" {% if edit_mode and product.current_details.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="l" {% if edit_mode and product.current_details.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="piece" {% if edit_mode and product.current_details.unit_of_measure == 'piece' %}selected{% endif %}>Stück</option>
</select> </select>
</div> </div>
<div class="mb-3 form-check"> <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> <label class="form-check-label" for="allow_fractional">Bruchteile erlauben</label>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label for="vat_rate" class="form-label">MwSt-Satz</label> <label for="vat_rate" class="form-label">MwSt-Satz</label>
<select class="form-select" id="vat_rate" name="vat_rate" required> <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="7" {% if edit_mode and product.current_details.vat_rate == '7' %}selected{% endif %}>7 %</option>
<option value="19" {% if edit_mode and product.vat_rate == '19' %}selected{% endif %}>19 %</option> <option value="19" {% if edit_mode and product.current_details.vat_rate == '19' %}selected{% endif %}>19 %</option>
</select> </select>
</div> </div>

View File

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

View File

@@ -12,6 +12,7 @@ from allmende_payment_system.models import (
Area, Area,
Permission, Permission,
Product, Product,
ProductDetails,
User, User,
UserGroup, UserGroup,
) )
@@ -219,9 +220,49 @@ def test_add_product(test_db, client, admin_user):
follow_redirects=False, follow_redirects=False,
) )
assert response.status_code == 303 assert response.status_code == 303
product = test_db.execute( product = test_db.execute(select(Product)).scalar()
select(Product).where(Product.name == "Test Product")
).scalar()
assert product is not None assert product is not None
assert product.name == "Test Product" assert len(product.details_history) == 1
assert product.price == Decimal("9.99") 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 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) order = Order(user=user)
total_amount = product.price total_amount = product.current_details.price
order.items.append( order.items.append(
OrderItem(product=product, quantity=1, total_amount=total_amount) 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): def product(test_db):
area = Area(**fake.area()) area = Area(**fake.area())
test_db.add(area) test_db.add(area)
product = Product(**fake.product()) product = Product.create(**fake.product())
product.area = area product.area = area
test_db.add(product) test_db.add(product)
test_db.flush() test_db.flush()
@@ -96,15 +96,17 @@ def test_edit_item_in_cart(client: APSTestClient, test_db, product):
cart = test_db.scalar(select(Order)) cart = test_db.scalar(select(Order))
assert len(cart.items) == 1 assert len(cart.items) == 1
assert cart.items[0].quantity == 3 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 = create_user_with_account(test_db, "test")
user.shopping_cart.items.append( 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() 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 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 = create_user_with_account(test_db, "test")
user.shopping_cart.items.append( 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() 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 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) user = create_user_with_account(test_db, "test", balance=100.0)
second_account = Account(name=f"Another account") 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.accounts.append(second_account)
user.shopping_cart.items.append( 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() 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 len(user.orders) == 2 # shopping cart + finalized order
assert user.accounts[0].balance == Decimal(100.0) 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") user = create_user_with_account(test_db, "test")
order = add_finalized_order_to_user(test_db, user, product) 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}") response = client.get(f"/shop/order/{order.id}")
assert response.status_code == 200 assert response.status_code == 200
assert f"Einkauf #{order.id}" in response.text 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") user = create_user_with_account(test_db, "test")
order = add_finalized_order_to_user(test_db, user, product) order = add_finalized_order_to_user(test_db, user, product)