refactor: move route handlers to module level for idiomatic FastHTML
- Routes are now at module level, accessible for import by templates - Templates accept action parameter (route function or URL string) - Routes pass themselves to templates for type-safe form actions - Changes DB access pattern from app.state.db to request.app.state.db - Registration uses rt(...)(func) pattern instead of @rt decorator This enables the idiomatic FastHTML pattern where forms can use action=route_function instead of action="/path/string", providing type safety and refactoring support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,6 +49,111 @@ def resolve_ducks_at_location(db: Any, location_id: str, ts_utc: int) -> list[st
|
|||||||
return [row[0] for row in rows]
|
return [row[0] for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def egg_index(request: Request):
|
||||||
|
"""GET / - Egg Quick Capture form."""
|
||||||
|
db = request.app.state.db
|
||||||
|
locations = LocationRepository(db).list_active()
|
||||||
|
|
||||||
|
# Check for pre-selected location from query params
|
||||||
|
selected_location_id = request.query_params.get("location_id")
|
||||||
|
|
||||||
|
return page(
|
||||||
|
egg_form(locations, selected_location_id=selected_location_id, action=product_collected),
|
||||||
|
title="Egg - AnimalTrack",
|
||||||
|
active_nav="egg",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def product_collected(request: Request):
|
||||||
|
"""POST /actions/product-collected - Record egg collection."""
|
||||||
|
db = request.app.state.db
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
# Extract form data
|
||||||
|
location_id = form.get("location_id", "")
|
||||||
|
quantity_str = form.get("quantity", "0")
|
||||||
|
notes = form.get("notes") or None
|
||||||
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
|
# Get locations for potential re-render
|
||||||
|
locations = LocationRepository(db).list_active()
|
||||||
|
|
||||||
|
# Validate location_id
|
||||||
|
if not location_id:
|
||||||
|
return _render_error_form(locations, None, "Please select a location")
|
||||||
|
|
||||||
|
# Validate quantity
|
||||||
|
try:
|
||||||
|
quantity = int(quantity_str)
|
||||||
|
except ValueError:
|
||||||
|
return _render_error_form(locations, location_id, "Quantity must be a number")
|
||||||
|
|
||||||
|
if quantity < 1:
|
||||||
|
return _render_error_form(locations, location_id, "Quantity must be at least 1")
|
||||||
|
|
||||||
|
# Get current timestamp
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
# Resolve ducks at location
|
||||||
|
resolved_ids = resolve_ducks_at_location(db, location_id, ts_utc)
|
||||||
|
|
||||||
|
if not resolved_ids:
|
||||||
|
return _render_error_form(locations, location_id, "No ducks at this location")
|
||||||
|
|
||||||
|
# Create product service
|
||||||
|
event_store = EventStore(db)
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(AnimalRegistryProjection(db))
|
||||||
|
registry.register(EventAnimalsProjection(db))
|
||||||
|
registry.register(IntervalProjection(db))
|
||||||
|
registry.register(ProductsProjection(db))
|
||||||
|
|
||||||
|
product_service = ProductService(db, event_store, registry)
|
||||||
|
|
||||||
|
# Create payload
|
||||||
|
payload = ProductCollectedPayload(
|
||||||
|
location_id=location_id,
|
||||||
|
product_code="egg.duck",
|
||||||
|
quantity=quantity,
|
||||||
|
resolved_ids=resolved_ids,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get actor from auth
|
||||||
|
auth = request.scope.get("auth")
|
||||||
|
actor = auth.username if auth else "unknown"
|
||||||
|
|
||||||
|
# Collect product
|
||||||
|
try:
|
||||||
|
product_service.collect_product(
|
||||||
|
payload=payload,
|
||||||
|
ts_utc=ts_utc,
|
||||||
|
actor=actor,
|
||||||
|
nonce=nonce,
|
||||||
|
route="/actions/product-collected",
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return _render_error_form(locations, location_id, str(e))
|
||||||
|
|
||||||
|
# Success: re-render form with location sticking, qty cleared
|
||||||
|
response = HTMLResponse(
|
||||||
|
content=str(
|
||||||
|
page(
|
||||||
|
egg_form(locations, selected_location_id=location_id, action=product_collected),
|
||||||
|
title="Egg - AnimalTrack",
|
||||||
|
active_nav="egg",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add toast trigger header
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{"showToast": {"message": f"Recorded {quantity} eggs", "type": "success"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def register_egg_routes(rt, app):
|
def register_egg_routes(rt, app):
|
||||||
"""Register egg capture routes.
|
"""Register egg capture routes.
|
||||||
|
|
||||||
@@ -56,111 +161,8 @@ def register_egg_routes(rt, app):
|
|||||||
rt: FastHTML route decorator.
|
rt: FastHTML route decorator.
|
||||||
app: FastHTML application instance.
|
app: FastHTML application instance.
|
||||||
"""
|
"""
|
||||||
|
rt("/")(egg_index)
|
||||||
@rt("/")
|
rt("/actions/product-collected", methods=["POST"])(product_collected)
|
||||||
def index(request: Request):
|
|
||||||
"""GET / - Egg Quick Capture form."""
|
|
||||||
db = app.state.db
|
|
||||||
locations = LocationRepository(db).list_active()
|
|
||||||
|
|
||||||
# Check for pre-selected location from query params
|
|
||||||
selected_location_id = request.query_params.get("location_id")
|
|
||||||
|
|
||||||
return page(
|
|
||||||
egg_form(locations, selected_location_id=selected_location_id),
|
|
||||||
title="Egg - AnimalTrack",
|
|
||||||
active_nav="egg",
|
|
||||||
)
|
|
||||||
|
|
||||||
@rt("/actions/product-collected", methods=["POST"])
|
|
||||||
async def product_collected(request: Request):
|
|
||||||
"""POST /actions/product-collected - Record egg collection."""
|
|
||||||
db = app.state.db
|
|
||||||
form = await request.form()
|
|
||||||
|
|
||||||
# Extract form data
|
|
||||||
location_id = form.get("location_id", "")
|
|
||||||
quantity_str = form.get("quantity", "0")
|
|
||||||
notes = form.get("notes") or None
|
|
||||||
nonce = form.get("nonce")
|
|
||||||
|
|
||||||
# Get locations for potential re-render
|
|
||||||
locations = LocationRepository(db).list_active()
|
|
||||||
|
|
||||||
# Validate location_id
|
|
||||||
if not location_id:
|
|
||||||
return _render_error_form(locations, None, "Please select a location")
|
|
||||||
|
|
||||||
# Validate quantity
|
|
||||||
try:
|
|
||||||
quantity = int(quantity_str)
|
|
||||||
except ValueError:
|
|
||||||
return _render_error_form(locations, location_id, "Quantity must be a number")
|
|
||||||
|
|
||||||
if quantity < 1:
|
|
||||||
return _render_error_form(locations, location_id, "Quantity must be at least 1")
|
|
||||||
|
|
||||||
# Get current timestamp
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# Resolve ducks at location
|
|
||||||
resolved_ids = resolve_ducks_at_location(db, location_id, ts_utc)
|
|
||||||
|
|
||||||
if not resolved_ids:
|
|
||||||
return _render_error_form(locations, location_id, "No ducks at this location")
|
|
||||||
|
|
||||||
# Create product service
|
|
||||||
event_store = EventStore(db)
|
|
||||||
registry = ProjectionRegistry()
|
|
||||||
registry.register(AnimalRegistryProjection(db))
|
|
||||||
registry.register(EventAnimalsProjection(db))
|
|
||||||
registry.register(IntervalProjection(db))
|
|
||||||
registry.register(ProductsProjection(db))
|
|
||||||
|
|
||||||
product_service = ProductService(db, event_store, registry)
|
|
||||||
|
|
||||||
# Create payload
|
|
||||||
payload = ProductCollectedPayload(
|
|
||||||
location_id=location_id,
|
|
||||||
product_code="egg.duck",
|
|
||||||
quantity=quantity,
|
|
||||||
resolved_ids=resolved_ids,
|
|
||||||
notes=notes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get actor from auth
|
|
||||||
auth = request.scope.get("auth")
|
|
||||||
actor = auth.username if auth else "unknown"
|
|
||||||
|
|
||||||
# Collect product
|
|
||||||
try:
|
|
||||||
product_service.collect_product(
|
|
||||||
payload=payload,
|
|
||||||
ts_utc=ts_utc,
|
|
||||||
actor=actor,
|
|
||||||
nonce=nonce,
|
|
||||||
route="/actions/product-collected",
|
|
||||||
)
|
|
||||||
except ValidationError as e:
|
|
||||||
return _render_error_form(locations, location_id, str(e))
|
|
||||||
|
|
||||||
# Success: re-render form with location sticking, qty cleared
|
|
||||||
response = HTMLResponse(
|
|
||||||
content=str(
|
|
||||||
page(
|
|
||||||
egg_form(locations, selected_location_id=location_id),
|
|
||||||
title="Egg - AnimalTrack",
|
|
||||||
active_nav="egg",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add toast trigger header
|
|
||||||
response.headers["HX-Trigger"] = json.dumps(
|
|
||||||
{"showToast": {"message": f"Recorded {quantity} eggs", "type": "success"}}
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _render_error_form(locations, selected_location_id, error_message):
|
def _render_error_form(locations, selected_location_id, error_message):
|
||||||
@@ -181,6 +183,7 @@ def _render_error_form(locations, selected_location_id, error_message):
|
|||||||
locations,
|
locations,
|
||||||
selected_location_id=selected_location_id,
|
selected_location_id=selected_location_id,
|
||||||
error=error_message,
|
error=error_message,
|
||||||
|
action=product_collected,
|
||||||
),
|
),
|
||||||
title="Egg - AnimalTrack",
|
title="Egg - AnimalTrack",
|
||||||
active_nav="egg",
|
active_nav="egg",
|
||||||
|
|||||||
@@ -38,6 +38,324 @@ def get_feed_balance(db: Any, feed_type_code: str) -> int | None:
|
|||||||
return row[0] if row else None
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def feed_index(request: Request):
|
||||||
|
"""GET /feed - Feed Quick Capture page."""
|
||||||
|
db = request.app.state.db
|
||||||
|
locations = LocationRepository(db).list_active()
|
||||||
|
feed_types = FeedTypeRepository(db).list_active()
|
||||||
|
|
||||||
|
# Check for active tab from query params
|
||||||
|
active_tab = request.query_params.get("tab", "give")
|
||||||
|
if active_tab not in ("give", "purchase"):
|
||||||
|
active_tab = "give"
|
||||||
|
|
||||||
|
return page(
|
||||||
|
feed_page(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
active_tab=active_tab,
|
||||||
|
give_action=feed_given,
|
||||||
|
purchase_action=feed_purchased,
|
||||||
|
),
|
||||||
|
title="Feed - AnimalTrack",
|
||||||
|
active_nav="feed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def feed_given(request: Request):
|
||||||
|
"""POST /actions/feed-given - Record feed given."""
|
||||||
|
db = request.app.state.db
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
# Extract form data
|
||||||
|
location_id = form.get("location_id", "")
|
||||||
|
feed_type_code = form.get("feed_type_code", "")
|
||||||
|
amount_kg_str = form.get("amount_kg", "0")
|
||||||
|
notes = form.get("notes") or None
|
||||||
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
|
# Get data for potential re-render
|
||||||
|
locations = LocationRepository(db).list_active()
|
||||||
|
feed_types = FeedTypeRepository(db).list_active()
|
||||||
|
|
||||||
|
# Find default amount from feed type
|
||||||
|
default_amount_kg = 20
|
||||||
|
for ft in feed_types:
|
||||||
|
if ft.code == feed_type_code:
|
||||||
|
default_amount_kg = ft.default_bag_size_kg
|
||||||
|
break
|
||||||
|
|
||||||
|
# Validate location_id
|
||||||
|
if not location_id:
|
||||||
|
return _render_give_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Please select a location",
|
||||||
|
selected_location_id=None,
|
||||||
|
selected_feed_type_code=feed_type_code or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate feed_type_code
|
||||||
|
if not feed_type_code:
|
||||||
|
return _render_give_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Please select a feed type",
|
||||||
|
selected_location_id=location_id,
|
||||||
|
selected_feed_type_code=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate amount_kg
|
||||||
|
try:
|
||||||
|
amount_kg = int(amount_kg_str)
|
||||||
|
except ValueError:
|
||||||
|
return _render_give_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Amount must be a number",
|
||||||
|
selected_location_id=location_id,
|
||||||
|
selected_feed_type_code=feed_type_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if amount_kg < 1:
|
||||||
|
return _render_give_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Amount must be at least 1 kg",
|
||||||
|
selected_location_id=location_id,
|
||||||
|
selected_feed_type_code=feed_type_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get current timestamp
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
# Create feed service
|
||||||
|
event_store = EventStore(db)
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(FeedInventoryProjection(db))
|
||||||
|
|
||||||
|
feed_service = FeedService(db, event_store, registry)
|
||||||
|
|
||||||
|
# Create payload
|
||||||
|
payload = FeedGivenPayload(
|
||||||
|
location_id=location_id,
|
||||||
|
feed_type_code=feed_type_code,
|
||||||
|
amount_kg=amount_kg,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get actor from auth
|
||||||
|
auth = request.scope.get("auth")
|
||||||
|
actor = auth.username if auth else "unknown"
|
||||||
|
|
||||||
|
# Give feed
|
||||||
|
try:
|
||||||
|
feed_service.give_feed(
|
||||||
|
payload=payload,
|
||||||
|
ts_utc=ts_utc,
|
||||||
|
actor=actor,
|
||||||
|
nonce=nonce,
|
||||||
|
route="/actions/feed-given",
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return _render_give_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
str(e),
|
||||||
|
selected_location_id=location_id,
|
||||||
|
selected_feed_type_code=feed_type_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for negative balance warning
|
||||||
|
balance = get_feed_balance(db, feed_type_code)
|
||||||
|
balance_warning = None
|
||||||
|
if balance is not None and balance < 0:
|
||||||
|
balance_warning = f"Warning: Inventory balance is now negative ({balance} kg)"
|
||||||
|
|
||||||
|
# Success: re-render form with location/type sticking, amount reset
|
||||||
|
response = HTMLResponse(
|
||||||
|
content=str(
|
||||||
|
page(
|
||||||
|
feed_page(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
active_tab="give",
|
||||||
|
selected_location_id=location_id,
|
||||||
|
selected_feed_type_code=feed_type_code,
|
||||||
|
default_amount_kg=default_amount_kg,
|
||||||
|
balance_warning=balance_warning,
|
||||||
|
give_action=feed_given,
|
||||||
|
purchase_action=feed_purchased,
|
||||||
|
),
|
||||||
|
title="Feed - AnimalTrack",
|
||||||
|
active_nav="feed",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add toast trigger header
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"showToast": {
|
||||||
|
"message": f"Recorded {amount_kg}kg {feed_type_code}",
|
||||||
|
"type": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def feed_purchased(request: Request):
|
||||||
|
"""POST /actions/feed-purchased - Record feed purchase."""
|
||||||
|
db = request.app.state.db
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
# Extract form data
|
||||||
|
feed_type_code = form.get("feed_type_code", "")
|
||||||
|
bag_size_kg_str = form.get("bag_size_kg", "0")
|
||||||
|
bags_count_str = form.get("bags_count", "0")
|
||||||
|
bag_price_cents_str = form.get("bag_price_cents", "0")
|
||||||
|
vendor = form.get("vendor") or None
|
||||||
|
notes = form.get("notes") or None
|
||||||
|
nonce = form.get("nonce")
|
||||||
|
|
||||||
|
# Get data for potential re-render
|
||||||
|
locations = LocationRepository(db).list_active()
|
||||||
|
feed_types = FeedTypeRepository(db).list_active()
|
||||||
|
|
||||||
|
# Validate feed_type_code
|
||||||
|
if not feed_type_code:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Please select a feed type",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate bag_size_kg
|
||||||
|
try:
|
||||||
|
bag_size_kg = int(bag_size_kg_str)
|
||||||
|
except ValueError:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Bag size must be a number",
|
||||||
|
)
|
||||||
|
|
||||||
|
if bag_size_kg < 1:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Bag size must be at least 1 kg",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate bags_count
|
||||||
|
try:
|
||||||
|
bags_count = int(bags_count_str)
|
||||||
|
except ValueError:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Bags count must be a number",
|
||||||
|
)
|
||||||
|
|
||||||
|
if bags_count < 1:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Bags count must be at least 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate bag_price_cents
|
||||||
|
try:
|
||||||
|
bag_price_cents = int(bag_price_cents_str)
|
||||||
|
except ValueError:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Price must be a number",
|
||||||
|
)
|
||||||
|
|
||||||
|
if bag_price_cents < 0:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
"Price cannot be negative",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get current timestamp
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
|
||||||
|
# Create feed service
|
||||||
|
event_store = EventStore(db)
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(FeedInventoryProjection(db))
|
||||||
|
|
||||||
|
feed_service = FeedService(db, event_store, registry)
|
||||||
|
|
||||||
|
# Create payload
|
||||||
|
payload = FeedPurchasedPayload(
|
||||||
|
feed_type_code=feed_type_code,
|
||||||
|
bag_size_kg=bag_size_kg,
|
||||||
|
bags_count=bags_count,
|
||||||
|
bag_price_cents=bag_price_cents,
|
||||||
|
vendor=vendor,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get actor from auth
|
||||||
|
auth = request.scope.get("auth")
|
||||||
|
actor = auth.username if auth else "unknown"
|
||||||
|
|
||||||
|
# Purchase feed
|
||||||
|
try:
|
||||||
|
feed_service.purchase_feed(
|
||||||
|
payload=payload,
|
||||||
|
ts_utc=ts_utc,
|
||||||
|
actor=actor,
|
||||||
|
nonce=nonce,
|
||||||
|
route="/actions/feed-purchased",
|
||||||
|
)
|
||||||
|
except ValidationError as e:
|
||||||
|
return _render_purchase_error(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total for toast
|
||||||
|
total_kg = bag_size_kg * bags_count
|
||||||
|
|
||||||
|
# Success: re-render form with fields cleared
|
||||||
|
response = HTMLResponse(
|
||||||
|
content=str(
|
||||||
|
page(
|
||||||
|
feed_page(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
active_tab="purchase",
|
||||||
|
give_action=feed_given,
|
||||||
|
purchase_action=feed_purchased,
|
||||||
|
),
|
||||||
|
title="Feed - AnimalTrack",
|
||||||
|
active_nav="feed",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add toast trigger header
|
||||||
|
response.headers["HX-Trigger"] = json.dumps(
|
||||||
|
{
|
||||||
|
"showToast": {
|
||||||
|
"message": f"Purchased {total_kg}kg {feed_type_code}",
|
||||||
|
"type": "success",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def register_feed_routes(rt, app):
|
def register_feed_routes(rt, app):
|
||||||
"""Register feed capture routes.
|
"""Register feed capture routes.
|
||||||
|
|
||||||
@@ -45,318 +363,9 @@ def register_feed_routes(rt, app):
|
|||||||
rt: FastHTML route decorator.
|
rt: FastHTML route decorator.
|
||||||
app: FastHTML application instance.
|
app: FastHTML application instance.
|
||||||
"""
|
"""
|
||||||
|
rt("/feed")(feed_index)
|
||||||
@rt("/feed")
|
rt("/actions/feed-given", methods=["POST"])(feed_given)
|
||||||
def feed_index(request: Request):
|
rt("/actions/feed-purchased", methods=["POST"])(feed_purchased)
|
||||||
"""GET /feed - Feed Quick Capture page."""
|
|
||||||
db = app.state.db
|
|
||||||
locations = LocationRepository(db).list_active()
|
|
||||||
feed_types = FeedTypeRepository(db).list_active()
|
|
||||||
|
|
||||||
# Check for active tab from query params
|
|
||||||
active_tab = request.query_params.get("tab", "give")
|
|
||||||
if active_tab not in ("give", "purchase"):
|
|
||||||
active_tab = "give"
|
|
||||||
|
|
||||||
return page(
|
|
||||||
feed_page(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
active_tab=active_tab,
|
|
||||||
),
|
|
||||||
title="Feed - AnimalTrack",
|
|
||||||
active_nav="feed",
|
|
||||||
)
|
|
||||||
|
|
||||||
@rt("/actions/feed-given", methods=["POST"])
|
|
||||||
async def feed_given(request: Request):
|
|
||||||
"""POST /actions/feed-given - Record feed given."""
|
|
||||||
db = app.state.db
|
|
||||||
form = await request.form()
|
|
||||||
|
|
||||||
# Extract form data
|
|
||||||
location_id = form.get("location_id", "")
|
|
||||||
feed_type_code = form.get("feed_type_code", "")
|
|
||||||
amount_kg_str = form.get("amount_kg", "0")
|
|
||||||
notes = form.get("notes") or None
|
|
||||||
nonce = form.get("nonce")
|
|
||||||
|
|
||||||
# Get data for potential re-render
|
|
||||||
locations = LocationRepository(db).list_active()
|
|
||||||
feed_types = FeedTypeRepository(db).list_active()
|
|
||||||
|
|
||||||
# Find default amount from feed type
|
|
||||||
default_amount_kg = 20
|
|
||||||
for ft in feed_types:
|
|
||||||
if ft.code == feed_type_code:
|
|
||||||
default_amount_kg = ft.default_bag_size_kg
|
|
||||||
break
|
|
||||||
|
|
||||||
# Validate location_id
|
|
||||||
if not location_id:
|
|
||||||
return _render_give_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Please select a location",
|
|
||||||
selected_location_id=None,
|
|
||||||
selected_feed_type_code=feed_type_code or None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate feed_type_code
|
|
||||||
if not feed_type_code:
|
|
||||||
return _render_give_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Please select a feed type",
|
|
||||||
selected_location_id=location_id,
|
|
||||||
selected_feed_type_code=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate amount_kg
|
|
||||||
try:
|
|
||||||
amount_kg = int(amount_kg_str)
|
|
||||||
except ValueError:
|
|
||||||
return _render_give_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Amount must be a number",
|
|
||||||
selected_location_id=location_id,
|
|
||||||
selected_feed_type_code=feed_type_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if amount_kg < 1:
|
|
||||||
return _render_give_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Amount must be at least 1 kg",
|
|
||||||
selected_location_id=location_id,
|
|
||||||
selected_feed_type_code=feed_type_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get current timestamp
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# Create feed service
|
|
||||||
event_store = EventStore(db)
|
|
||||||
registry = ProjectionRegistry()
|
|
||||||
registry.register(FeedInventoryProjection(db))
|
|
||||||
|
|
||||||
feed_service = FeedService(db, event_store, registry)
|
|
||||||
|
|
||||||
# Create payload
|
|
||||||
payload = FeedGivenPayload(
|
|
||||||
location_id=location_id,
|
|
||||||
feed_type_code=feed_type_code,
|
|
||||||
amount_kg=amount_kg,
|
|
||||||
notes=notes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get actor from auth
|
|
||||||
auth = request.scope.get("auth")
|
|
||||||
actor = auth.username if auth else "unknown"
|
|
||||||
|
|
||||||
# Give feed
|
|
||||||
try:
|
|
||||||
feed_service.give_feed(
|
|
||||||
payload=payload,
|
|
||||||
ts_utc=ts_utc,
|
|
||||||
actor=actor,
|
|
||||||
nonce=nonce,
|
|
||||||
route="/actions/feed-given",
|
|
||||||
)
|
|
||||||
except ValidationError as e:
|
|
||||||
return _render_give_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
str(e),
|
|
||||||
selected_location_id=location_id,
|
|
||||||
selected_feed_type_code=feed_type_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for negative balance warning
|
|
||||||
balance = get_feed_balance(db, feed_type_code)
|
|
||||||
balance_warning = None
|
|
||||||
if balance is not None and balance < 0:
|
|
||||||
balance_warning = f"Warning: Inventory balance is now negative ({balance} kg)"
|
|
||||||
|
|
||||||
# Success: re-render form with location/type sticking, amount reset
|
|
||||||
response = HTMLResponse(
|
|
||||||
content=str(
|
|
||||||
page(
|
|
||||||
feed_page(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
active_tab="give",
|
|
||||||
selected_location_id=location_id,
|
|
||||||
selected_feed_type_code=feed_type_code,
|
|
||||||
default_amount_kg=default_amount_kg,
|
|
||||||
balance_warning=balance_warning,
|
|
||||||
),
|
|
||||||
title="Feed - AnimalTrack",
|
|
||||||
active_nav="feed",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add toast trigger header
|
|
||||||
response.headers["HX-Trigger"] = json.dumps(
|
|
||||||
{
|
|
||||||
"showToast": {
|
|
||||||
"message": f"Recorded {amount_kg}kg {feed_type_code}",
|
|
||||||
"type": "success",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
@rt("/actions/feed-purchased", methods=["POST"])
|
|
||||||
async def feed_purchased(request: Request):
|
|
||||||
"""POST /actions/feed-purchased - Record feed purchase."""
|
|
||||||
db = app.state.db
|
|
||||||
form = await request.form()
|
|
||||||
|
|
||||||
# Extract form data
|
|
||||||
feed_type_code = form.get("feed_type_code", "")
|
|
||||||
bag_size_kg_str = form.get("bag_size_kg", "0")
|
|
||||||
bags_count_str = form.get("bags_count", "0")
|
|
||||||
bag_price_cents_str = form.get("bag_price_cents", "0")
|
|
||||||
vendor = form.get("vendor") or None
|
|
||||||
notes = form.get("notes") or None
|
|
||||||
nonce = form.get("nonce")
|
|
||||||
|
|
||||||
# Get data for potential re-render
|
|
||||||
locations = LocationRepository(db).list_active()
|
|
||||||
feed_types = FeedTypeRepository(db).list_active()
|
|
||||||
|
|
||||||
# Validate feed_type_code
|
|
||||||
if not feed_type_code:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Please select a feed type",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate bag_size_kg
|
|
||||||
try:
|
|
||||||
bag_size_kg = int(bag_size_kg_str)
|
|
||||||
except ValueError:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Bag size must be a number",
|
|
||||||
)
|
|
||||||
|
|
||||||
if bag_size_kg < 1:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Bag size must be at least 1 kg",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate bags_count
|
|
||||||
try:
|
|
||||||
bags_count = int(bags_count_str)
|
|
||||||
except ValueError:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Bags count must be a number",
|
|
||||||
)
|
|
||||||
|
|
||||||
if bags_count < 1:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Bags count must be at least 1",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate bag_price_cents
|
|
||||||
try:
|
|
||||||
bag_price_cents = int(bag_price_cents_str)
|
|
||||||
except ValueError:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Price must be a number",
|
|
||||||
)
|
|
||||||
|
|
||||||
if bag_price_cents < 0:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
"Price cannot be negative",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get current timestamp
|
|
||||||
ts_utc = int(time.time() * 1000)
|
|
||||||
|
|
||||||
# Create feed service
|
|
||||||
event_store = EventStore(db)
|
|
||||||
registry = ProjectionRegistry()
|
|
||||||
registry.register(FeedInventoryProjection(db))
|
|
||||||
|
|
||||||
feed_service = FeedService(db, event_store, registry)
|
|
||||||
|
|
||||||
# Create payload
|
|
||||||
payload = FeedPurchasedPayload(
|
|
||||||
feed_type_code=feed_type_code,
|
|
||||||
bag_size_kg=bag_size_kg,
|
|
||||||
bags_count=bags_count,
|
|
||||||
bag_price_cents=bag_price_cents,
|
|
||||||
vendor=vendor,
|
|
||||||
notes=notes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get actor from auth
|
|
||||||
auth = request.scope.get("auth")
|
|
||||||
actor = auth.username if auth else "unknown"
|
|
||||||
|
|
||||||
# Purchase feed
|
|
||||||
try:
|
|
||||||
feed_service.purchase_feed(
|
|
||||||
payload=payload,
|
|
||||||
ts_utc=ts_utc,
|
|
||||||
actor=actor,
|
|
||||||
nonce=nonce,
|
|
||||||
route="/actions/feed-purchased",
|
|
||||||
)
|
|
||||||
except ValidationError as e:
|
|
||||||
return _render_purchase_error(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
str(e),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Calculate total for toast
|
|
||||||
total_kg = bag_size_kg * bags_count
|
|
||||||
|
|
||||||
# Success: re-render form with fields cleared
|
|
||||||
response = HTMLResponse(
|
|
||||||
content=str(
|
|
||||||
page(
|
|
||||||
feed_page(
|
|
||||||
locations,
|
|
||||||
feed_types,
|
|
||||||
active_tab="purchase",
|
|
||||||
),
|
|
||||||
title="Feed - AnimalTrack",
|
|
||||||
active_nav="feed",
|
|
||||||
)
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add toast trigger header
|
|
||||||
response.headers["HX-Trigger"] = json.dumps(
|
|
||||||
{
|
|
||||||
"showToast": {
|
|
||||||
"message": f"Purchased {total_kg}kg {feed_type_code}",
|
|
||||||
"type": "success",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
|
|
||||||
def _render_give_error(
|
def _render_give_error(
|
||||||
@@ -388,6 +397,8 @@ def _render_give_error(
|
|||||||
selected_location_id=selected_location_id,
|
selected_location_id=selected_location_id,
|
||||||
selected_feed_type_code=selected_feed_type_code,
|
selected_feed_type_code=selected_feed_type_code,
|
||||||
give_error=error_message,
|
give_error=error_message,
|
||||||
|
give_action=feed_given,
|
||||||
|
purchase_action=feed_purchased,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
@@ -416,6 +427,8 @@ def _render_purchase_error(locations, feed_types, error_message):
|
|||||||
feed_types,
|
feed_types,
|
||||||
active_tab="purchase",
|
active_tab="purchase",
|
||||||
purchase_error=error_message,
|
purchase_error=error_message,
|
||||||
|
give_action=feed_given,
|
||||||
|
purchase_action=feed_purchased,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# ABOUTME: Templates for Egg Quick Capture form.
|
# ABOUTME: Templates for Egg Quick Capture form.
|
||||||
# ABOUTME: Provides form components for recording egg collections.
|
# ABOUTME: Provides form components for recording egg collections.
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H2, Form, Hidden, Option
|
from fasthtml.common import H2, Form, Hidden, Option
|
||||||
from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
from monsterui.all import Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
@@ -12,6 +15,7 @@ def egg_form(
|
|||||||
locations: list[Location],
|
locations: list[Location],
|
||||||
selected_location_id: str | None = None,
|
selected_location_id: str | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
|
action: Callable[..., Any] | str = "/actions/product-collected",
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Egg Quick Capture form.
|
"""Create the Egg Quick Capture form.
|
||||||
|
|
||||||
@@ -19,6 +23,7 @@ def egg_form(
|
|||||||
locations: List of active locations for the dropdown.
|
locations: List of active locations for the dropdown.
|
||||||
selected_location_id: Pre-selected location ID (sticks after submission).
|
selected_location_id: Pre-selected location ID (sticks after submission).
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
|
action: Route function or URL string for form submission.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for egg collection.
|
Form component for egg collection.
|
||||||
@@ -82,7 +87,7 @@ def egg_form(
|
|||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Eggs", type="submit", cls=ButtonT.primary),
|
Button("Record Eggs", type="submit", cls=ButtonT.primary),
|
||||||
# Form submission via standard action/method (hx-boost handles AJAX)
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
action="/actions/product-collected",
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# ABOUTME: Templates for Feed Quick Capture forms.
|
# ABOUTME: Templates for Feed Quick Capture forms.
|
||||||
# ABOUTME: Provides form components for recording feed given and purchases.
|
# ABOUTME: Provides form components for recording feed given and purchases.
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
|
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
|
||||||
from monsterui.all import (
|
from monsterui.all import (
|
||||||
Button,
|
Button,
|
||||||
@@ -25,6 +28,8 @@ def feed_page(
|
|||||||
give_error: str | None = None,
|
give_error: str | None = None,
|
||||||
purchase_error: str | None = None,
|
purchase_error: str | None = None,
|
||||||
balance_warning: str | None = None,
|
balance_warning: str | None = None,
|
||||||
|
give_action: Callable[..., Any] | str = "/actions/feed-given",
|
||||||
|
purchase_action: Callable[..., Any] | str = "/actions/feed-purchased",
|
||||||
):
|
):
|
||||||
"""Create the Feed Quick Capture page with tabbed forms.
|
"""Create the Feed Quick Capture page with tabbed forms.
|
||||||
|
|
||||||
@@ -38,6 +43,8 @@ def feed_page(
|
|||||||
give_error: Error message for give form.
|
give_error: Error message for give form.
|
||||||
purchase_error: Error message for purchase form.
|
purchase_error: Error message for purchase form.
|
||||||
balance_warning: Warning about negative inventory balance.
|
balance_warning: Warning about negative inventory balance.
|
||||||
|
give_action: Route function or URL for give feed form.
|
||||||
|
purchase_action: Route function or URL for purchase feed form.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Page content with tabbed forms.
|
Page content with tabbed forms.
|
||||||
@@ -76,11 +83,12 @@ def feed_page(
|
|||||||
default_amount_kg=default_amount_kg,
|
default_amount_kg=default_amount_kg,
|
||||||
error=give_error,
|
error=give_error,
|
||||||
balance_warning=balance_warning,
|
balance_warning=balance_warning,
|
||||||
|
action=give_action,
|
||||||
),
|
),
|
||||||
cls="uk-active" if give_active else "",
|
cls="uk-active" if give_active else "",
|
||||||
),
|
),
|
||||||
Li(
|
Li(
|
||||||
purchase_feed_form(feed_types, error=purchase_error),
|
purchase_feed_form(feed_types, error=purchase_error, action=purchase_action),
|
||||||
cls="" if give_active else "uk-active",
|
cls="" if give_active else "uk-active",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -96,6 +104,7 @@ def give_feed_form(
|
|||||||
default_amount_kg: int | None = None,
|
default_amount_kg: int | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
balance_warning: str | None = None,
|
balance_warning: str | None = None,
|
||||||
|
action: Callable[..., Any] | str = "/actions/feed-given",
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Give Feed form.
|
"""Create the Give Feed form.
|
||||||
|
|
||||||
@@ -107,6 +116,7 @@ def give_feed_form(
|
|||||||
default_amount_kg: Default value for amount field.
|
default_amount_kg: Default value for amount field.
|
||||||
error: Error message to display.
|
error: Error message to display.
|
||||||
balance_warning: Warning about negative balance.
|
balance_warning: Warning about negative balance.
|
||||||
|
action: Route function or URL for form submission.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for giving feed.
|
Form component for giving feed.
|
||||||
@@ -196,7 +206,7 @@ def give_feed_form(
|
|||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Feed Given", type="submit", cls=ButtonT.primary),
|
Button("Record Feed Given", type="submit", cls=ButtonT.primary),
|
||||||
action="/actions/feed-given",
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
@@ -205,12 +215,14 @@ def give_feed_form(
|
|||||||
def purchase_feed_form(
|
def purchase_feed_form(
|
||||||
feed_types: list[FeedType],
|
feed_types: list[FeedType],
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
|
action: Callable[..., Any] | str = "/actions/feed-purchased",
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Purchase Feed form.
|
"""Create the Purchase Feed form.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
feed_types: List of active feed types.
|
feed_types: List of active feed types.
|
||||||
error: Error message to display.
|
error: Error message to display.
|
||||||
|
action: Route function or URL for form submission.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for purchasing feed.
|
Form component for purchasing feed.
|
||||||
@@ -290,7 +302,7 @@ def purchase_feed_form(
|
|||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Purchase", type="submit", cls=ButtonT.primary),
|
Button("Record Purchase", type="submit", cls=ButtonT.primary),
|
||||||
action="/actions/feed-purchased",
|
action=action,
|
||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user