From fd544fcebc7af2c8814a2a98aefb4dcbdfd8c634 Mon Sep 17 00:00:00 2001 From: Niklas Meinzer Date: Sat, 13 Dec 2025 11:53:41 +0100 Subject: [PATCH] feat(order): Add finalize order functionality --- .../api/dependencies.py | 2 +- src/allmende_payment_system/api/shop.py | 6 +- src/allmende_payment_system/database.py | 12 ++++ src/allmende_payment_system/models.py | 42 ++++++++--- .../templates/index.html.jinja | 2 +- src/allmende_payment_system/tools.py | 2 +- test/conftest.py | 28 +++----- test/fake_data.py | 31 ++++++++ test/test_shop.py | 71 +++++++++++++++++++ 9 files changed, 161 insertions(+), 35 deletions(-) create mode 100644 test/fake_data.py create mode 100644 test/test_shop.py diff --git a/src/allmende_payment_system/api/dependencies.py b/src/allmende_payment_system/api/dependencies.py index 1295243..95dc882 100644 --- a/src/allmende_payment_system/api/dependencies.py +++ b/src/allmende_payment_system/api/dependencies.py @@ -41,4 +41,4 @@ async def get_user_object(request: Request, session: SessionDep) -> User: return user -UserDep = Annotated[dict, Depends(get_user_object)] +UserDep = Annotated[User, Depends(get_user_object)] diff --git a/src/allmende_payment_system/api/shop.py b/src/allmende_payment_system/api/shop.py index a6b9eee..c5a64dc 100644 --- a/src/allmende_payment_system/api/shop.py +++ b/src/allmende_payment_system/api/shop.py @@ -34,18 +34,18 @@ async def get_cart(request: Request, session: SessionDep, user: UserDep): @shop_router.get("/shop/finalize_order") -async def get_cart(request: Request, session: SessionDep, user: UserDep): +async def finalize_order(request: Request, session: SessionDep, user: UserDep): cart = user.shopping_cart # TODO: Implement - cart.finalize_order() + cart.finalize(user.accounts[0]) return RedirectResponse(url=f"/", status_code=status.HTTP_302_FOUND) @shop_router.get("/shop/area/{area_id}") -async def get_shop(request: Request, session: SessionDep, area_id: int): +async def get_shop_area(request: Request, session: SessionDep, area_id: int): query = select(Area).where(Area.id == area_id) area = session.scalars(query).one() return templates.TemplateResponse( diff --git a/src/allmende_payment_system/database.py b/src/allmende_payment_system/database.py index 1b6c6b8..5bff15b 100644 --- a/src/allmende_payment_system/database.py +++ b/src/allmende_payment_system/database.py @@ -15,6 +15,18 @@ def create_tables(): def ensure_user(user_info: dict, session: Session) -> User: + """ + Retrieve an existing user or create a new one if it doesn't exist. + This function queries the database for a user with the given username. + If found, it returns the existing user. If not found, it creates a new user + with the provided information, adds it to the session, and returns it. + + :param user_info: Dictionary containing user information with keys: + - "username" (str): The unique username to search for or create + - "display_name" (str, optional): The display name for the new user + :param session: SQLAlchemy session for database operations + :return: The existing or newly created user object + """ statement = select(User).where(User.username == user_info["username"]) if user := session.scalars(statement).one_or_none(): diff --git a/src/allmende_payment_system/models.py b/src/allmende_payment_system/models.py index b82beb9..96d6ed0 100644 --- a/src/allmende_payment_system/models.py +++ b/src/allmende_payment_system/models.py @@ -58,7 +58,7 @@ class User(Base): @property def shopping_cart(self): for order in self.orders: - if order.account_id is None: + if order.transaction is None: cart = order break else: @@ -109,10 +109,7 @@ class Order(Base): user_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "user.id")) user: Mapped[User] = relationship("User", back_populates="orders") - account_id: Mapped[int] = mapped_column( - ForeignKey(TABLE_PREFIX + "account.id"), nullable=True - ) - account: Mapped[Account | None] = relationship("Account") + transaction: Mapped["Transaction | None"] = relationship("Transaction") items: Mapped[list["OrderItem"]] = relationship( "OrderItem", cascade="all, delete-orphan", back_populates="order" @@ -120,12 +117,37 @@ class Order(Base): @property def is_in_shopping_cart(self): - return self.account is None + return self.transaction is None @property def total_amount(self): return sum(item.total_amount for item in self.items) + def finalize(self, account: Account): + """ + Moves the order from the shopping cart to a given account + and adds a transaction to the account. + + :param account: The account to which the order should be finalized + :raises ValueError: If the order is already finalized or empty""" + if not self.is_in_shopping_cart: + raise ValueError("Order is already finalized.") + + if not self.items: + raise ValueError("Cannot finalize an empty order.") + + assert account in self.user.accounts, "Account does not belong to user." + + # create a transaction for the order + transaction = Transaction( + type="order", + total_amount=-self.total_amount, + order=self, + account=account, + ) + session = object_session(self) + session.add(transaction) + class OrderItem(Base): __tablename__ = TABLE_PREFIX + "order_item" @@ -142,7 +164,7 @@ class OrderItem(Base): TransactionTypes = typing.Literal[ - "product", + "order", "deposit", "withdrawal", "expense", @@ -161,10 +183,10 @@ class Transaction(Base): Numeric(10, 2), nullable=False ) - product_id: Mapped[int] = mapped_column( - ForeignKey(TABLE_PREFIX + "product.id"), nullable=True + order_id: Mapped[int] = mapped_column( + ForeignKey(TABLE_PREFIX + "order.id"), nullable=True ) - product: Mapped["Product"] = relationship("Product") + order: Mapped["Order"] = relationship("Order", back_populates="transaction") account_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "account.id")) account: Mapped["Account"] = relationship("Account", back_populates="transactions") diff --git a/src/allmende_payment_system/templates/index.html.jinja b/src/allmende_payment_system/templates/index.html.jinja index 817ea01..f73dbc2 100644 --- a/src/allmende_payment_system/templates/index.html.jinja +++ b/src/allmende_payment_system/templates/index.html.jinja @@ -45,7 +45,7 @@ {% for transaction in transactions[:10] %}
-
{{ transaction.product.name or transaction.type|transaction_type_de }}
+
{{ transaction.type|transaction_type_de }}
{{ transaction.timestamp }} diff --git a/src/allmende_payment_system/tools.py b/src/allmende_payment_system/tools.py index 8433e65..dc80968 100644 --- a/src/allmende_payment_system/tools.py +++ b/src/allmende_payment_system/tools.py @@ -7,7 +7,7 @@ TRANSACTION_TYPE_DE = { "deposit": "Einzahlung", "withdrawal": "Auszahlung", "expense": "Auslage", - "product": "Einkauf", + "order": "Einkauf", } UNITS_OF_MEASURE = {"piece": "Stück"} diff --git a/test/conftest.py b/test/conftest.py index 28f7f3c..0f90af9 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -28,14 +28,14 @@ Base.metadata.create_all(bind=connection) # Bind sessions to the single connection so all sessions share the same DB TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=connection) +test_session = None + def get_test_session(): """Dependency override for get_session""" - db = TestSessionLocal() - try: - yield db - finally: - db.close() + assert test_session is not None, "test_session is not set" + yield test_session + test_session.flush() app.dependency_overrides[get_session] = get_test_session @@ -57,21 +57,11 @@ def unauthorized_client(): def test_db(): """Provides a database session for direct test usage""" db = TestSessionLocal() - - # Start a SAVEPOINT so test changes can be rolled back without - # closing the shared connection. Also restart the nested transaction - # when the session issues commits internally. - db.begin_nested() - - @event.listens_for(db, "after_transaction_end") - def restart_savepoint(session, transaction): - # If the nested transaction ended, re-open it for continued isolation - if transaction.nested and not session.is_active: - session.begin_nested() - + global test_session + test_session = db try: yield db finally: - # Rollback to the SAVEPOINT and close the session to clean up + test_session = None db.rollback() - db.close() \ No newline at end of file + db.close() diff --git a/test/fake_data.py b/test/fake_data.py new file mode 100644 index 0000000..5a7746f --- /dev/null +++ b/test/fake_data.py @@ -0,0 +1,31 @@ +from decimal import Decimal + +from faker import Faker +from faker.providers import BaseProvider + +fake = Faker() + + +class MyProvider(BaseProvider): + def product(self) -> dict: + return { + "name": fake.text(max_nb_chars=10), + "price": Decimal( + fake.pyfloat(left_digits=2, right_digits=2, positive=True) + ), + "unit_of_measure": fake.random_element(elements=["kg", "g", "l", "piece"]), + "vat_rate": fake.random_element(elements=[7, 19]), + } + + def area(self) -> dict: + return { + "name": fake.text(max_nb_chars=10), + "description": fake.text(max_nb_chars=100), + } + + def order(self) -> dict: + return {} + + +# then add new provider to faker instance +fake.add_provider(MyProvider) diff --git a/test/test_shop.py b/test/test_shop.py new file mode 100644 index 0000000..64697aa --- /dev/null +++ b/test/test_shop.py @@ -0,0 +1,71 @@ +from decimal import Decimal + +from fake_data import fake +from starlette.testclient import TestClient + +from allmende_payment_system.database import ensure_user +from allmende_payment_system.models import ( + Account, + Area, + Order, + OrderItem, + Product, + Transaction, +) + + +def create_user_with_account(test_db, username: str, balance: float | None = None): + user_info = {"username": username, "display_name": f"Display {username}"} + user = ensure_user(user_info, test_db) + account = Account(name=f"Account for {username}") + test_db.add(account) + user.accounts.append(account) + + if balance is not None: + user.accounts[0].transactions.append( + Transaction(total_amount=Decimal(balance), type="deposit") + ) + test_db.flush() + return user + + +def test_add_item_to_cart(client: TestClient, test_db): + area = Area(**fake.area()) + test_db.add(area) + product = Product(**fake.product()) + product.area = area + test_db.add(product) + test_db.flush() + form_data = {"product_id": product.id, "quantity": 2, "area_id": area.id} + + response = client.post( + "/shop/cart/add", + data=form_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + follow_redirects=False, + ) + assert response.status_code == 302 + + +def test_finalize_order(client: TestClient, test_db): + area = Area(**fake.area()) + test_db.add(area) + product = Product(**fake.product()) + product.area = area + test_db.add(product) + test_db.flush() + + user = create_user_with_account(test_db, "test", balance=100.0) + + user.shopping_cart.items.append( + OrderItem(product=product, quantity=2, total_amount=product.price * 2) + ) + test_db.flush() + + response = client.get("/shop/finalize_order", follow_redirects=False) + assert response.status_code == 302 + + assert len(user.shopping_cart.items) == 0 + assert len(user.orders) == 2 # shopping cart + finalized order + + assert user.accounts[0].balance == Decimal(100.0) - (product.price * 2)