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
|
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")
|
@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
|
cart = user.shopping_cart
|
||||||
|
|
||||||
# TODO: Implement
|
# TODO: Implement
|
||||||
cart.finalize_order()
|
cart.finalize(user.accounts[0])
|
||||||
|
|
||||||
return RedirectResponse(url=f"/", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url=f"/", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
|
|
||||||
@shop_router.get("/shop/area/{area_id}")
|
@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)
|
query = select(Area).where(Area.id == area_id)
|
||||||
area = session.scalars(query).one()
|
area = session.scalars(query).one()
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ def create_tables():
|
|||||||
|
|
||||||
|
|
||||||
def ensure_user(user_info: dict, session: Session) -> User:
|
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"])
|
statement = select(User).where(User.username == user_info["username"])
|
||||||
|
|
||||||
if user := session.scalars(statement).one_or_none():
|
if user := session.scalars(statement).one_or_none():
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ class User(Base):
|
|||||||
@property
|
@property
|
||||||
def shopping_cart(self):
|
def shopping_cart(self):
|
||||||
for order in self.orders:
|
for order in self.orders:
|
||||||
if order.account_id is None:
|
if order.transaction is None:
|
||||||
cart = order
|
cart = order
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
@@ -109,10 +109,7 @@ class Order(Base):
|
|||||||
user_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "user.id"))
|
user_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "user.id"))
|
||||||
user: Mapped[User] = relationship("User", back_populates="orders")
|
user: Mapped[User] = relationship("User", back_populates="orders")
|
||||||
|
|
||||||
account_id: Mapped[int] = mapped_column(
|
transaction: Mapped["Transaction | None"] = relationship("Transaction")
|
||||||
ForeignKey(TABLE_PREFIX + "account.id"), nullable=True
|
|
||||||
)
|
|
||||||
account: Mapped[Account | None] = relationship("Account")
|
|
||||||
|
|
||||||
items: Mapped[list["OrderItem"]] = relationship(
|
items: Mapped[list["OrderItem"]] = relationship(
|
||||||
"OrderItem", cascade="all, delete-orphan", back_populates="order"
|
"OrderItem", cascade="all, delete-orphan", back_populates="order"
|
||||||
@@ -120,12 +117,37 @@ class Order(Base):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_in_shopping_cart(self):
|
def is_in_shopping_cart(self):
|
||||||
return self.account is None
|
return self.transaction is None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def total_amount(self):
|
def total_amount(self):
|
||||||
return sum(item.total_amount for item in self.items)
|
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):
|
class OrderItem(Base):
|
||||||
__tablename__ = TABLE_PREFIX + "order_item"
|
__tablename__ = TABLE_PREFIX + "order_item"
|
||||||
@@ -142,7 +164,7 @@ class OrderItem(Base):
|
|||||||
|
|
||||||
|
|
||||||
TransactionTypes = typing.Literal[
|
TransactionTypes = typing.Literal[
|
||||||
"product",
|
"order",
|
||||||
"deposit",
|
"deposit",
|
||||||
"withdrawal",
|
"withdrawal",
|
||||||
"expense",
|
"expense",
|
||||||
@@ -161,10 +183,10 @@ class Transaction(Base):
|
|||||||
Numeric(10, 2), nullable=False
|
Numeric(10, 2), nullable=False
|
||||||
)
|
)
|
||||||
|
|
||||||
product_id: Mapped[int] = mapped_column(
|
order_id: Mapped[int] = mapped_column(
|
||||||
ForeignKey(TABLE_PREFIX + "product.id"), nullable=True
|
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_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "account.id"))
|
||||||
account: Mapped["Account"] = relationship("Account", back_populates="transactions")
|
account: Mapped["Account"] = relationship("Account", back_populates="transactions")
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
{% for transaction in transactions[:10] %}
|
{% for transaction in transactions[:10] %}
|
||||||
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
|
<div class="list-group-item d-flex justify-content-between align-items-start py-3">
|
||||||
<div class="flex-grow-1">
|
<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">
|
<small class="text-muted">
|
||||||
{{ transaction.timestamp }}
|
{{ transaction.timestamp }}
|
||||||
</small>
|
</small>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ TRANSACTION_TYPE_DE = {
|
|||||||
"deposit": "Einzahlung",
|
"deposit": "Einzahlung",
|
||||||
"withdrawal": "Auszahlung",
|
"withdrawal": "Auszahlung",
|
||||||
"expense": "Auslage",
|
"expense": "Auslage",
|
||||||
"product": "Einkauf",
|
"order": "Einkauf",
|
||||||
}
|
}
|
||||||
|
|
||||||
UNITS_OF_MEASURE = {"piece": "Stück"}
|
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
|
# Bind sessions to the single connection so all sessions share the same DB
|
||||||
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=connection)
|
TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=connection)
|
||||||
|
|
||||||
|
test_session = None
|
||||||
|
|
||||||
|
|
||||||
def get_test_session():
|
def get_test_session():
|
||||||
"""Dependency override for get_session"""
|
"""Dependency override for get_session"""
|
||||||
db = TestSessionLocal()
|
assert test_session is not None, "test_session is not set"
|
||||||
try:
|
yield test_session
|
||||||
yield db
|
test_session.flush()
|
||||||
finally:
|
|
||||||
db.close()
|
|
||||||
|
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = get_test_session
|
app.dependency_overrides[get_session] = get_test_session
|
||||||
@@ -57,21 +57,11 @@ def unauthorized_client():
|
|||||||
def test_db():
|
def test_db():
|
||||||
"""Provides a database session for direct test usage"""
|
"""Provides a database session for direct test usage"""
|
||||||
db = TestSessionLocal()
|
db = TestSessionLocal()
|
||||||
|
global test_session
|
||||||
# Start a SAVEPOINT so test changes can be rolled back without
|
test_session = db
|
||||||
# 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()
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield db
|
yield db
|
||||||
finally:
|
finally:
|
||||||
# Rollback to the SAVEPOINT and close the session to clean up
|
test_session = None
|
||||||
db.rollback()
|
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