diff --git a/alembic/versions/3b61a6fbc6b2_introduce_product_details.py b/alembic/versions/3b61a6fbc6b2_introduce_product_details.py new file mode 100644 index 0000000..75a151e --- /dev/null +++ b/alembic/versions/3b61a6fbc6b2_introduce_product_details.py @@ -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 ### diff --git a/alembic/versions/958d7aee2b21_initial_model.py b/alembic/versions/958d7aee2b21_initial_model.py index e692849..2286bdc 100644 --- a/alembic/versions/958d7aee2b21_initial_model.py +++ b/alembic/versions/958d7aee2b21_initial_model.py @@ -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 ### diff --git a/src/allmende_payment_system/api/admin.py b/src/allmende_payment_system/api/admin.py index 2287690..610d71a 100644 --- a/src/allmende_payment_system/api/admin.py +++ b/src/allmende_payment_system/api/admin.py @@ -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 diff --git a/src/allmende_payment_system/api/shop.py b/src/allmende_payment_system/api/shop.py index 4c4eaec..e45f699 100644 --- a/src/allmende_payment_system/api/shop.py +++ b/src/allmende_payment_system/api/shop.py @@ -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 diff --git a/src/allmende_payment_system/models.py b/src/allmende_payment_system/models.py index 3926a84..8ff93dd 100644 --- a/src/allmende_payment_system/models.py +++ b/src/allmende_payment_system/models.py @@ -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[ diff --git a/src/allmende_payment_system/templates/area.html.jinja b/src/allmende_payment_system/templates/area.html.jinja index 2476eea..46ec9ec 100644 --- a/src/allmende_payment_system/templates/area.html.jinja +++ b/src/allmende_payment_system/templates/area.html.jinja @@ -12,19 +12,12 @@
- {# - {{ product.name }}#}
-
{{ product.name }}
-

{{ product.description }}

+
{{ product.current_details.name }}
+

{{ product.current_details.description }}

- {{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }} + {{ product.current_details.price|format_number }} € pro {{ product.current_details.unit_of_measure|units_of_measure_de }}
@@ -98,7 +84,7 @@ {# TODO: Make this a global function #}