Fix egg sale form: remove duplicate route, change price to euros
All checks were successful
Deploy / deploy (push) Successful in 2m50s
All checks were successful
Deploy / deploy (push) Successful in 2m50s
The egg sale form had two issues: - Duplicate POST /actions/product-sold route in products.py was overwriting the eggs.py handler, causing incomplete page responses (no tabs, no recent sales list) - Price input used cents while feed purchase uses euros, inconsistent UX Changes: - Remove duplicate handler from products.py (keep only redirect) - Change sell form price input from cents to euros (consistent with feed) - Parse euros in handler, convert to cents for storage - Add TestEggSale class with 4 tests for the fixed behavior Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user