feat(order): Add finalize order functionality
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ TRANSACTION_TYPE_DE = {
|
||||
"deposit": "Einzahlung",
|
||||
"withdrawal": "Auszahlung",
|
||||
"expense": "Auslage",
|
||||
"product": "Einkauf",
|
||||
"order": "Einkauf",
|
||||
}
|
||||
|
||||
UNITS_OF_MEASURE = {"piece": "Stück"}
|
||||
|
||||
@@ -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()
|
||||
db.close()
|
||||
|
||||
31
test/fake_data.py
Normal file
31
test/fake_data.py
Normal 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
71
test/test_shop.py
Normal 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)
|
||||
Reference in New Issue
Block a user