diff --git a/pyproject.toml b/pyproject.toml index e019bce..e600cc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ build-backend = "uv_build" [dependency-groups] dev = [ "black>=25.9.0", + "faker>=38.2.0", "httpx>=0.28.1", "isort>=7.0.0", "pytest>=8.4.2", diff --git a/src/allmende_payment_system/api/__init__.py b/src/allmende_payment_system/api/__init__.py index 3f0a390..9ae8d3c 100644 --- a/src/allmende_payment_system/api/__init__.py +++ b/src/allmende_payment_system/api/__init__.py @@ -11,7 +11,6 @@ templates = get_jinja_renderer() @root_router.get("/") async def landing_page(request: Request, user: UserDep, session: SessionDep): - print(f"User {user.username} ({user.display_name}) accessed landing page") transactions = [] for account in user.accounts: transactions += account.transactions diff --git a/src/allmende_payment_system/api/dependencies.py b/src/allmende_payment_system/api/dependencies.py index 67cf694..1295243 100644 --- a/src/allmende_payment_system/api/dependencies.py +++ b/src/allmende_payment_system/api/dependencies.py @@ -13,6 +13,7 @@ def get_session() -> Session: try: yield db finally: + db.commit() db.close() diff --git a/src/allmende_payment_system/api/shop.py b/src/allmende_payment_system/api/shop.py index 9a7d868..a6b9eee 100644 --- a/src/allmende_payment_system/api/shop.py +++ b/src/allmende_payment_system/api/shop.py @@ -1,8 +1,12 @@ +from decimal import Decimal + from fastapi import APIRouter, Request from sqlalchemy import select +from starlette import status +from starlette.responses import RedirectResponse -from allmende_payment_system.api import SessionDep -from allmende_payment_system.models import Area +from allmende_payment_system.api.dependencies import SessionDep, UserDep +from allmende_payment_system.models import Area, OrderItem, Product from allmende_payment_system.tools import get_jinja_renderer shop_router = APIRouter() @@ -20,6 +24,26 @@ async def get_shop(request: Request, session: SessionDep): ) +@shop_router.get("/shop/cart") +async def get_cart(request: Request, session: SessionDep, user: UserDep): + + return templates.TemplateResponse( + "cart.html.jinja", + context={"request": request, "user": user}, + ) + + +@shop_router.get("/shop/finalize_order") +async def get_cart(request: Request, session: SessionDep, user: UserDep): + + cart = user.shopping_cart + + # TODO: Implement + cart.finalize_order() + + 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): query = select(Area).where(Area.id == area_id) @@ -28,3 +52,27 @@ async def get_shop(request: Request, session: SessionDep, area_id: int): "area.html.jinja", context={"request": request, "area": area}, ) + + +@shop_router.post("/shop/cart/add") +async def add_to_cart(request: Request, session: SessionDep, user: UserDep): + form_data = await request.form() + + query = select(Product).where(Product.id == form_data["product_id"]) + product = session.scalars(query).one() + + quantity = Decimal(form_data["quantity"]) + total_amount = product.price * quantity + + order_item = OrderItem( + product=product, quantity=quantity, total_amount=total_amount + ) + session.add(order_item) + cart = user.shopping_cart + cart.items.append(order_item) + + session.flush() + + return RedirectResponse( + url=f"/shop/area/{form_data['area_id']}", status_code=status.HTTP_302_FOUND + ) diff --git a/src/allmende_payment_system/database.py b/src/allmende_payment_system/database.py index 2ed4cec..1b6c6b8 100644 --- a/src/allmende_payment_system/database.py +++ b/src/allmende_payment_system/database.py @@ -24,6 +24,6 @@ def ensure_user(user_info: dict, session: Session) -> User: username=user_info["username"], display_name=user_info.get("display_name") ) session.add(user) - session.commit() + session.flush() return user diff --git a/src/allmende_payment_system/models.py b/src/allmende_payment_system/models.py index 3d19a8b..b82beb9 100644 --- a/src/allmende_payment_system/models.py +++ b/src/allmende_payment_system/models.py @@ -82,6 +82,7 @@ class Area(Base): UnitsOfMeasure = typing.Literal[ "g", "kg", + "l", "piece", ] @@ -121,6 +122,10 @@ class Order(Base): def is_in_shopping_cart(self): return self.account is None + @property + def total_amount(self): + return sum(item.total_amount for item in self.items) + class OrderItem(Base): __tablename__ = TABLE_PREFIX + "order_item" @@ -130,7 +135,7 @@ class OrderItem(Base): product_id: Mapped[int] = mapped_column(ForeignKey(TABLE_PREFIX + "product.id")) product: Mapped[Product] = relationship("Product") - quantity: Mapped[int] = mapped_column(nullable=False) + quantity: Mapped[decimal.Decimal] = mapped_column(nullable=False) total_amount: Mapped[decimal.Decimal] = mapped_column( Numeric(10, 2), nullable=False ) diff --git a/src/allmende_payment_system/templates/area.html.jinja b/src/allmende_payment_system/templates/area.html.jinja index b7ec129..94b2938 100644 --- a/src/allmende_payment_system/templates/area.html.jinja +++ b/src/allmende_payment_system/templates/area.html.jinja @@ -25,12 +25,85 @@

{{ product.description }}

{{ product.price|format_number }} € pro {{ product.unit_of_measure|units_of_measure_de }} - + +
+ + + + {# TODO: Make this a global function #} + {% endfor %} + + {% endblock %} diff --git a/src/allmende_payment_system/templates/base.html.jinja b/src/allmende_payment_system/templates/base.html.jinja index 466d7ac..20eadf6 100644 --- a/src/allmende_payment_system/templates/base.html.jinja +++ b/src/allmende_payment_system/templates/base.html.jinja @@ -29,21 +29,21 @@ Einkaufen - - +{# #} +{# #}
- + diff --git a/src/allmende_payment_system/templates/cart.html.jinja b/src/allmende_payment_system/templates/cart.html.jinja new file mode 100644 index 0000000..4d78641 --- /dev/null +++ b/src/allmende_payment_system/templates/cart.html.jinja @@ -0,0 +1,74 @@ +{% extends "base.html.jinja" %} +{% block content %} +
+

Warenkorb

+

Überprüfe deine Artikel und fahre zur Kasse fort.

+
+ + {% set items = user.shopping_cart.items %} + + {% if items|length == 0 %} +
Dein Warenkorb ist leer. Weiter einkaufen.
+ {% else %} +
+ + + + + + + + + + + + {% set total = namespace(value=0) %} + {% for item in items %} + {% set price = item.price if item.price is defined else (item.unit_price if item.unit_price is defined else 0) %} + {% set qty = item.quantity if item.quantity is defined else (item.qty if item.qty is defined else 1) %} + {% set subtotal = price * qty %} + {% set total.value = total.value + subtotal %} + + + + + + + + {% endfor %} + + + + + + + + +
ArtikelMengeEinzelpreisSumme
+
{{ item.product.name }}
+
{{ item.description or '' }}
+
+
+ + +
+
{{ item.product.price | format_number }} €{{ item.total_amount | format_number }} € + Entfernen +
Gesamtsumme{{ user.shopping_cart.total_amount | format_number }} €
+
+ +
+
+ Warenkorb leeren + Jetzt Buchen +
+
+ {% endif %} + +{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/test/conftest.py b/test/conftest.py index 71b3596..28f7f3c 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,34 +3,42 @@ from unittest import mock import pytest from fastapi.testclient import TestClient -from sqlalchemy import StaticPool, create_engine +from sqlalchemy import StaticPool, create_engine, event from sqlalchemy.orm import sessionmaker from allmende_payment_system.api.dependencies import get_session from allmende_payment_system.app import app from allmende_payment_system.models import Base +engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) -def make_db(): - engine = create_engine( - "sqlite:///:memory:", - connect_args={"check_same_thread": False}, - poolclass=StaticPool, - ) - Base.metadata.create_all(bind=engine) # Create tables - return sessionmaker(autocommit=False, autoflush=False, bind=engine) +# Create a single connection and an outer transaction which we can rollback +# at the end of the test run. Individual tests will use nested transactions +# (SAVEPOINTs) for isolation. This ensures the TestClient (app) and the +# test fixture sessions see the same in-memory database state. +connection = engine.connect() +transaction = connection.begin() + +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) -def make_in_memory_session(): - db = make_db() - session = db() +def get_test_session(): + """Dependency override for get_session""" + db = TestSessionLocal() try: - yield session + yield db finally: - session.close() + db.close() -app.dependency_overrides[get_session] = make_in_memory_session +app.dependency_overrides[get_session] = get_test_session @pytest.fixture(scope="session") @@ -45,7 +53,25 @@ def unauthorized_client(): return TestClient(app) -@pytest.fixture +@pytest.fixture(scope="function") def test_db(): - db = make_db() - return 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() + + try: + yield db + finally: + # Rollback to the SAVEPOINT and close the session to clean up + db.rollback() + db.close() \ No newline at end of file diff --git a/test/test_database.py b/test/test_database.py index 0edfdef..de5a0a3 100644 --- a/test/test_database.py +++ b/test/test_database.py @@ -11,7 +11,7 @@ def test_ensure_user(test_db): assert user.username == "test" - test_db.commit() + test_db.flush() assert test_db.scalar(select(func.count()).select_from(User)) == 1 diff --git a/uv.lock b/uv.lock index ec7436f..2756239 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "black" }, + { name = "faker" }, { name = "httpx" }, { name = "isort" }, { name = "pytest" }, @@ -28,6 +29,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=25.9.0" }, + { name = "faker", specifier = ">=38.2.0" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "isort", specifier = ">=7.0.0" }, { name = "pytest", specifier = ">=8.4.2" }, @@ -128,6 +130,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, ] +[[package]] +name = "faker" +version = "38.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/27/022d4dbd4c20567b4c294f79a133cc2f05240ea61e0d515ead18c995c249/faker-38.2.0.tar.gz", hash = "sha256:20672803db9c7cb97f9b56c18c54b915b6f1d8991f63d1d673642dc43f5ce7ab", size = 1941469, upload-time = "2025-11-19T16:37:31.892Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/93/00c94d45f55c336434a15f98d906387e87ce28f9918e4444829a8fda432d/faker-38.2.0-py3-none-any.whl", hash = "sha256:35fe4a0a79dee0dc4103a6083ee9224941e7d3594811a50e3969e547b0d2ee65", size = 1980505, upload-time = "2025-11-19T16:37:30.208Z" }, +] + [[package]] name = "fastapi" version = "0.119.0" @@ -204,6 +218,8 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" }, + { url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" }, { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, ] @@ -672,6 +688,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + [[package]] name = "urllib3" version = "2.5.0"