Fix egg sale form: remove duplicate route, change price to euros
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:
2026-01-22 07:35:02 +00:00
parent 51e502ed10
commit ffef49b931
5 changed files with 109 additions and 201 deletions

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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

View File

@@ -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",
},