diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index 10f6513..ba38742 100644 --- a/src/animaltrack/web/routes/eggs.py +++ b/src/animaltrack/web/routes/eggs.py @@ -547,7 +547,7 @@ async def product_sold(request: Request, session): # Extract form data product_code = form.get("product_code", "") quantity_str = form.get("quantity", "0") - total_price_str = form.get("total_price_cents", "0") + total_price_str = form.get("total_price_euros", "0") buyer = form.get("buyer") or None notes = form.get("notes") or None nonce = form.get("nonce") @@ -566,7 +566,7 @@ async def product_sold(request: Request, session): None, "Please select a product", quantity=quantity_str, - total_price_cents=total_price_str, + total_price_euros=total_price_str, buyer=buyer, notes=notes, ) @@ -583,7 +583,7 @@ async def product_sold(request: Request, session): product_code, "Quantity must be a number", quantity=quantity_str, - total_price_cents=total_price_str, + total_price_euros=total_price_str, buyer=buyer, notes=notes, ) @@ -597,14 +597,15 @@ async def product_sold(request: Request, session): product_code, "Quantity must be at least 1", quantity=quantity_str, - total_price_cents=total_price_str, + total_price_euros=total_price_str, buyer=buyer, notes=notes, ) - # Validate total_price_cents + # Validate total_price_euros and convert to cents try: - total_price_cents = int(total_price_str) + total_price_euros = float(total_price_str) + total_price_cents = int(round(total_price_euros * 100)) except ValueError: return _render_sell_error( request, @@ -614,7 +615,7 @@ async def product_sold(request: Request, session): product_code, "Total price must be a number", quantity=quantity_str, - total_price_cents=total_price_str, + total_price_euros=total_price_str, buyer=buyer, notes=notes, ) @@ -628,7 +629,7 @@ async def product_sold(request: Request, session): product_code, "Total price cannot be negative", quantity=quantity_str, - total_price_cents=total_price_str, + total_price_euros=total_price_str, buyer=buyer, notes=notes, ) @@ -671,7 +672,7 @@ async def product_sold(request: Request, session): product_code, str(e), quantity=quantity_str, - total_price_cents=total_price_str, + total_price_euros=total_price_str, buyer=buyer, notes=notes, ) @@ -763,7 +764,7 @@ def _render_sell_error( selected_product_code, error_message, quantity: str | None = None, - total_price_cents: str | None = None, + total_price_euros: str | None = None, buyer: str | None = None, notes: str | None = None, ): @@ -777,7 +778,7 @@ def _render_sell_error( selected_product_code: Currently selected product code. error_message: Error message to display. quantity: Quantity value to preserve. - total_price_cents: Total price value to preserve. + total_price_euros: Total price value to preserve. buyer: Buyer value to preserve. notes: Notes value to preserve. @@ -798,7 +799,7 @@ def _render_sell_error( harvest_action=product_collected, sell_action=product_sold, sell_quantity=quantity, - sell_total_price_cents=total_price_cents, + sell_total_price_euros=total_price_euros, sell_buyer=buyer, sell_notes=notes, **display_data, diff --git a/src/animaltrack/web/routes/products.py b/src/animaltrack/web/routes/products.py index 3c0618e..7b470ed 100644 --- a/src/animaltrack/web/routes/products.py +++ b/src/animaltrack/web/routes/products.py @@ -1,47 +1,19 @@ # ABOUTME: Routes for Product Sold functionality. -# ABOUTME: Handles GET /sell form and POST /actions/product-sold. +# ABOUTME: Redirects GET /sell to Eggs page Sell tab. POST handled by eggs.py. from __future__ import annotations -import json -import time - -from fasthtml.common import APIRouter, to_xml +from fasthtml.common import APIRouter from starlette.requests import Request -from starlette.responses import HTMLResponse - -from animaltrack.events.payloads import ProductSoldPayload -from animaltrack.events.store import EventStore -from animaltrack.projections import EventLogProjection, ProjectionRegistry -from animaltrack.projections.products import ProductsProjection -from animaltrack.repositories.products import ProductRepository -from animaltrack.services.products import ProductService, ValidationError -from animaltrack.web.templates import render_page -from animaltrack.web.templates.products import product_sold_form +from starlette.responses import RedirectResponse # APIRouter for multi-file route organization ar = APIRouter() -def _get_sellable_products(db): - """Get list of active, sellable products. - - Args: - db: Database connection. - - Returns: - List of sellable Product objects. - """ - repo = ProductRepository(db) - all_products = repo.list_all() - return [p for p in all_products if p.active and p.sellable] - - @ar("/sell") def sell_index(request: Request): """GET /sell - Redirect to Eggs page Sell tab.""" - from starlette.responses import RedirectResponse - # Preserve product_code if provided product_code = request.query_params.get("product_code") redirect_url = "/?tab=sell" @@ -49,130 +21,3 @@ def sell_index(request: Request): redirect_url = f"/?tab=sell&product_code={product_code}" return RedirectResponse(url=redirect_url, status_code=302) - - -@ar("/actions/product-sold", methods=["POST"]) -async def product_sold(request: Request): - """POST /actions/product-sold - Record product sale.""" - db = request.app.state.db - form = await request.form() - - # Extract form data - product_code = form.get("product_code", "") - quantity_str = form.get("quantity", "0") - total_price_str = form.get("total_price_cents", "0") - buyer = form.get("buyer") or None - notes = form.get("notes") or None - nonce = form.get("nonce") - - # Get products for potential re-render - products = _get_sellable_products(db) - - # Validate product_code - if not product_code: - return _render_error_form(request, products, None, "Please select a product") - - # Validate quantity - try: - quantity = int(quantity_str) - except ValueError: - return _render_error_form(request, products, product_code, "Quantity must be a number") - - if quantity < 1: - return _render_error_form(request, products, product_code, "Quantity must be at least 1") - - # Validate total_price_cents - try: - total_price_cents = int(total_price_str) - except ValueError: - return _render_error_form(request, products, product_code, "Total price must be a number") - - if total_price_cents < 0: - return _render_error_form(request, products, product_code, "Total price cannot be negative") - - # Get current timestamp - ts_utc = int(time.time() * 1000) - - # Create product service - event_store = EventStore(db) - registry = ProjectionRegistry() - registry.register(ProductsProjection(db)) - registry.register(EventLogProjection(db)) - - product_service = ProductService(db, event_store, registry) - - # Create payload - payload = ProductSoldPayload( - product_code=product_code, - quantity=quantity, - total_price_cents=total_price_cents, - buyer=buyer, - notes=notes, - ) - - # Get actor from auth - auth = request.scope.get("auth") - actor = auth.username if auth else "unknown" - - # Sell product - try: - product_service.sell_product( - payload=payload, - ts_utc=ts_utc, - actor=actor, - nonce=nonce, - route="/actions/product-sold", - ) - except ValidationError as e: - return _render_error_form(request, products, product_code, str(e)) - - # Success: re-render form with product sticking, other fields cleared - response = HTMLResponse( - content=to_xml( - render_page( - request, - product_sold_form( - products, selected_product_code=product_code, action=product_sold - ), - title="Sell - AnimalTrack", - active_nav=None, - ) - ), - ) - - # Add toast trigger header - response.headers["HX-Trigger"] = json.dumps( - {"showToast": {"message": f"Recorded sale of {quantity} {product_code}", "type": "success"}} - ) - - return response - - -def _render_error_form(request, products, selected_product_code, error_message): - """Render form with error message. - - Args: - request: The Starlette request object. - products: List of sellable products. - selected_product_code: Currently selected product code. - error_message: Error message to display. - - Returns: - HTMLResponse with 422 status. - """ - return HTMLResponse( - content=to_xml( - render_page( - request, - product_sold_form( - products, - selected_product_code=selected_product_code, - error=error_message, - action=product_sold, - ), - title="Sell - AnimalTrack", - active_nav=None, - ) - ), - status_code=422, - ) diff --git a/src/animaltrack/web/templates/eggs.py b/src/animaltrack/web/templates/eggs.py index 0545a85..4048bb1 100644 --- a/src/animaltrack/web/templates/eggs.py +++ b/src/animaltrack/web/templates/eggs.py @@ -44,7 +44,7 @@ def eggs_page( harvest_quantity: str | None = None, harvest_notes: str | None = None, sell_quantity: str | None = None, - sell_total_price_cents: str | None = None, + sell_total_price_euros: str | None = None, sell_buyer: str | None = None, sell_notes: str | None = None, ): @@ -71,7 +71,7 @@ def eggs_page( harvest_quantity: Preserved quantity value on error. harvest_notes: Preserved notes value on error. sell_quantity: Preserved quantity value on error. - sell_total_price_cents: Preserved total price value on error. + sell_total_price_euros: Preserved total price value on error. sell_buyer: Preserved buyer value on error. sell_notes: Preserved notes value on error. @@ -119,7 +119,7 @@ def eggs_page( recent_events=sell_events, sales_stats=sales_stats, default_quantity=sell_quantity, - default_total_price_cents=sell_total_price_cents, + default_total_price_euros=sell_total_price_euros, default_buyer=sell_buyer, default_notes=sell_notes, ), @@ -270,7 +270,7 @@ def sell_form( recent_events: list[tuple[Event, bool]] | None = None, sales_stats: dict | None = None, default_quantity: str | None = None, - default_total_price_cents: str | None = None, + default_total_price_euros: str | None = None, default_buyer: str | None = None, default_notes: str | None = None, ) -> Div: @@ -284,7 +284,7 @@ def sell_form( recent_events: Recent (Event, is_deleted) tuples, most recent first. sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales. default_quantity: Preserved quantity value on error. - default_total_price_cents: Preserved total price value on error. + default_total_price_euros: Preserved total price value on error. default_buyer: Preserved buyer value on error. default_notes: Preserved notes value on error. @@ -363,17 +363,17 @@ def sell_form( required=True, value=default_quantity or "", ), - # Total price in cents + # Total price in euros LabelInput( - "Total Price (cents)", - id="total_price_cents", - name="total_price_cents", + "Total Price (€)", + id="total_price_euros", + name="total_price_euros", type="number", min="0", - step="1", - placeholder="Total price in cents", + step="0.01", + placeholder="e.g., 12.50", required=True, - value=default_total_price_cents or "", + value=default_total_price_euros or "", ), # Optional buyer LabelInput( diff --git a/tests/test_web_eggs.py b/tests/test_web_eggs.py index 69249a3..ac9cd7e 100644 --- a/tests/test_web_eggs.py +++ b/tests/test_web_eggs.py @@ -365,3 +365,66 @@ class TestEggCollectionAnimalFiltering: "Juvenile should NOT be associated with egg collection" ) assert len(associated_ids) == 1, "Only adult females should be associated" + + +class TestEggSale: + """Tests for POST /actions/product-sold from eggs page.""" + + def test_sell_form_accepts_euros(self, client, seeded_db): + """Price input should accept decimal euros like feed purchase.""" + resp = client.post( + "/actions/product-sold", + data={ + "product_code": "egg.duck", + "quantity": "10", + "total_price_euros": "12.50", # Euros, not cents + "nonce": "test-nonce-sell-euros-1", + }, + ) + assert resp.status_code == 200 + + # Event should store 1250 cents + import json + + event_row = seeded_db.execute( + "SELECT entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1" + ).fetchone() + entity_refs = json.loads(event_row[0]) + assert entity_refs["total_price_cents"] == 1250 + + def test_sell_response_includes_tabs(self, client, seeded_db): + """After recording sale, response should include full page with tabs.""" + resp = client.post( + "/actions/product-sold", + data={ + "product_code": "egg.duck", + "quantity": "10", + "total_price_euros": "15.00", + "nonce": "test-nonce-sell-tabs-1", + }, + ) + assert resp.status_code == 200 + # Should have both tabs (proving it's the full eggs page) + assert "Harvest" in resp.text + assert "Sell" in resp.text + + def test_sell_response_includes_recent_sales(self, client, seeded_db): + """After recording sale, response should include recent sales section.""" + resp = client.post( + "/actions/product-sold", + data={ + "product_code": "egg.duck", + "quantity": "10", + "total_price_euros": "15.00", + "nonce": "test-nonce-sell-recent-1", + }, + ) + assert resp.status_code == 200 + assert "Recent Sales" in resp.text + + def test_sell_form_has_euros_field(self, client): + """Sell form should have total_price_euros field, not total_price_cents.""" + resp = client.get("/?tab=sell") + assert resp.status_code == 200 + assert 'name="total_price_euros"' in resp.text + assert "Total Price" in resp.text diff --git a/tests/test_web_products_sold.py b/tests/test_web_products_sold.py index de30bfa..319e649 100644 --- a/tests/test_web_products_sold.py +++ b/tests/test_web_products_sold.py @@ -59,10 +59,10 @@ class TestProductSoldFormRendering: assert 'name="quantity"' in resp.text or 'id="quantity"' in resp.text def test_sell_form_has_total_price_field(self, client): - """Form has total_price_cents input field.""" + """Form has total_price_euros input field.""" resp = client.get("/sell") assert resp.status_code == 200 - assert 'name="total_price_cents"' in resp.text or 'id="total_price_cents"' in resp.text + assert 'name="total_price_euros"' in resp.text or 'id="total_price_euros"' in resp.text def test_sell_form_has_buyer_field(self, client): """Form has optional buyer input field.""" @@ -89,7 +89,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "30", - "total_price_cents": "1500", + "total_price_euros": "15.00", "buyer": "Local Market", "notes": "Weekly sale", "nonce": "test-nonce-sold-1", @@ -113,7 +113,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "30", - "total_price_cents": "1500", + "total_price_euros": "15.00", "nonce": "test-nonce-sold-2", }, ) @@ -136,7 +136,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "3", - "total_price_cents": "1000", + "total_price_euros": "10.00", "nonce": "test-nonce-sold-3", }, ) @@ -158,7 +158,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "0", - "total_price_cents": "1000", + "total_price_euros": "10.00", "nonce": "test-nonce-sold-4", }, ) @@ -172,7 +172,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "-1", - "total_price_cents": "1000", + "total_price_euros": "10.00", "nonce": "test-nonce-sold-5", }, ) @@ -186,7 +186,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "10", - "total_price_cents": "-100", + "total_price_euros": "-1.00", "nonce": "test-nonce-sold-6", }, ) @@ -199,7 +199,7 @@ class TestProductSold: "/actions/product-sold", data={ "quantity": "10", - "total_price_cents": "1000", + "total_price_euros": "10.00", "nonce": "test-nonce-sold-7", }, ) @@ -213,30 +213,29 @@ class TestProductSold: data={ "product_code": "invalid.product", "quantity": "10", - "total_price_cents": "1000", + "total_price_euros": "10.00", "nonce": "test-nonce-sold-8", }, ) assert resp.status_code == 422 - def test_product_sold_success_shows_toast(self, client): - """Successful sale returns response with toast trigger.""" + def test_product_sold_success_returns_full_page(self, client): + """Successful sale returns full eggs page with tabs.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "12", - "total_price_cents": "600", + "total_price_euros": "6.00", "nonce": "test-nonce-sold-9", }, ) assert resp.status_code == 200 - # Check for HX-Trigger header with showToast - hx_trigger = resp.headers.get("HX-Trigger") - assert hx_trigger is not None - assert "showToast" in hx_trigger + # Should return full eggs page with tabs (toast via session) + assert "Harvest" in resp.text + assert "Sell" in resp.text def test_product_sold_optional_buyer(self, client, seeded_db): """Buyer field is optional.""" @@ -245,7 +244,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "10", - "total_price_cents": "500", + "total_price_euros": "5.00", "nonce": "test-nonce-sold-10", }, ) @@ -265,7 +264,7 @@ class TestProductSold: data={ "product_code": "egg.duck", "quantity": "10", - "total_price_cents": "500", + "total_price_euros": "5.00", "buyer": "Test Buyer", "nonce": "test-nonce-sold-11", },