Introduce product details table
This commit is contained in:
112
alembic/versions/3b61a6fbc6b2_introduce_product_details.py
Normal file
112
alembic/versions/3b61a6fbc6b2_introduce_product_details.py
Normal 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 ###
|
||||
@@ -1,10 +1,11 @@
|
||||
"""Initial model
|
||||
|
||||
Revision ID: 958d7aee2b21
|
||||
Revises:
|
||||
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 ###
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[
|
||||
|
||||
@@ -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 + ' €';
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user