feat(order): Add finalize order functionality

This commit is contained in:
2025-12-13 11:53:41 +01:00
parent 00246819cc
commit fd544fcebc
9 changed files with 161 additions and 35 deletions

View File

@@ -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)]

View File

@@ -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(

View File

@@ -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():

View File

@@ -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")

View File

@@ -45,7 +45,7 @@
{% for transaction in transactions[:10] %}
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
<div class="flex-grow-1">
<div class="fw-semibold">{{ transaction.product.name or transaction.type|transaction_type_de }}</div>
<div class="fw-semibold">{{ transaction.type|transaction_type_de }}</div>
<small class="text-muted">
{{ transaction.timestamp }}
</small>

View File

@@ -7,7 +7,7 @@ TRANSACTION_TYPE_DE = {
"deposit": "Einzahlung",
"withdrawal": "Auszahlung",
"expense": "Auslage",
"product": "Einkauf",
"order": "Einkauf",
}
UNITS_OF_MEASURE = {"piece": "Stück"}

View File

@@ -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()

31
test/fake_data.py Normal file
View File

@@ -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)

71
test/test_shop.py Normal file
View File

@@ -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)