feat: implement Feed Quick Capture form (Step 7.4)
Add /feed page with tabbed forms for Give Feed and Purchase Feed: - GET /feed renders page with tabs (Give Feed default, Purchase Feed) - POST /actions/feed-given records feed given to a location - POST /actions/feed-purchased records feed purchases to inventory Also adopts idiomatic FastHTML/HTMX pattern: - Add hx-boost to base template for automatic AJAX on forms - Refactor egg form to use action/method instead of hx_post Spec §22 compliance: - Integer kg only, min=1 - Warn if inventory negative (but allow) - Toast + stay on page after submit - Location/type stick, amount resets to default bag size 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
17
PLAN.md
17
PLAN.md
@@ -279,14 +279,15 @@ Check off items as completed. Each phase builds on the previous.
|
|||||||
- [x] Write tests: form renders, POST creates event, validation errors (422)
|
- [x] Write tests: form renders, POST creates event, validation errors (422)
|
||||||
- [x] **Commit checkpoint** (e9804cd)
|
- [x] **Commit checkpoint** (e9804cd)
|
||||||
|
|
||||||
### Step 7.4: Feed Quick Capture
|
### Step 7.4: Feed Quick Capture ✓
|
||||||
- [ ] Create `web/routes/feed.py`:
|
- [x] Create `web/routes/feed.py`:
|
||||||
- [ ] GET /feed - Feed Quick Capture form
|
- [x] GET /feed - Feed Quick Capture form
|
||||||
- [ ] POST /actions/feed-given
|
- [x] POST /actions/feed-given
|
||||||
- [ ] POST /actions/feed-purchased
|
- [x] POST /actions/feed-purchased
|
||||||
- [ ] Create `web/templates/feed.py` with forms
|
- [x] Create `web/templates/feed.py` with forms
|
||||||
- [ ] Implement defaults per spec §22
|
- [x] Implement defaults per spec §22
|
||||||
- [ ] Write tests: form renders, POST creates events, blocked without purchase
|
- [x] Write tests: form renders, POST creates events, blocked without purchase
|
||||||
|
- [x] Adopt hx-boost pattern (idiomatic FastHTML/HTMX)
|
||||||
- [ ] **Commit checkpoint**
|
- [ ] **Commit checkpoint**
|
||||||
|
|
||||||
### Step 7.5: Move Animals
|
### Step 7.5: Move Animals
|
||||||
|
|||||||
@@ -92,3 +92,29 @@ class FeedTypeRepository:
|
|||||||
)
|
)
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def list_active(self) -> list[FeedType]:
|
||||||
|
"""Get all active feed types.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of active feed types.
|
||||||
|
"""
|
||||||
|
rows = self.db.execute(
|
||||||
|
"""
|
||||||
|
SELECT code, name, default_bag_size_kg, protein_pct, active, created_at_utc, updated_at_utc
|
||||||
|
FROM feed_types
|
||||||
|
WHERE active = 1
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
FeedType(
|
||||||
|
code=row[0],
|
||||||
|
name=row[1],
|
||||||
|
default_bag_size_kg=row[2],
|
||||||
|
protein_pct=row[3],
|
||||||
|
active=bool(row[4]),
|
||||||
|
created_at_utc=row[5],
|
||||||
|
updated_at_utc=row[6],
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from animaltrack.web.middleware import (
|
|||||||
csrf_before,
|
csrf_before,
|
||||||
request_id_before,
|
request_id_before,
|
||||||
)
|
)
|
||||||
from animaltrack.web.routes import register_egg_routes, register_health_routes
|
from animaltrack.web.routes import register_egg_routes, register_feed_routes, register_health_routes
|
||||||
|
|
||||||
# Default static directory relative to this module
|
# Default static directory relative to this module
|
||||||
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
|
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||||
@@ -126,5 +126,6 @@ def create_app(
|
|||||||
# Register routes
|
# Register routes
|
||||||
register_health_routes(rt, app)
|
register_health_routes(rt, app)
|
||||||
register_egg_routes(rt, app)
|
register_egg_routes(rt, app)
|
||||||
|
register_feed_routes(rt, app)
|
||||||
|
|
||||||
return app, rt
|
return app, rt
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# ABOUTME: Contains modular route handlers for different features.
|
# ABOUTME: Contains modular route handlers for different features.
|
||||||
|
|
||||||
from animaltrack.web.routes.eggs import register_egg_routes
|
from animaltrack.web.routes.eggs import register_egg_routes
|
||||||
|
from animaltrack.web.routes.feed import register_feed_routes
|
||||||
from animaltrack.web.routes.health import register_health_routes
|
from animaltrack.web.routes.health import register_health_routes
|
||||||
|
|
||||||
__all__ = ["register_egg_routes", "register_health_routes"]
|
__all__ = ["register_egg_routes", "register_feed_routes", "register_health_routes"]
|
||||||
|
|||||||
425
src/animaltrack/web/routes/feed.py
Normal file
425
src/animaltrack/web/routes/feed.py
Normal file
@@ -0,0 +1,425 @@
|
|||||||
|
# ABOUTME: Routes for Feed Quick Capture functionality.
|
||||||
|
# ABOUTME: Handles GET /feed form and POST /actions/feed-given, /actions/feed-purchased.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
|
||||||
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.projections import ProjectionRegistry
|
||||||
|
from animaltrack.projections.feed import FeedInventoryProjection
|
||||||
|
from animaltrack.repositories.feed_types import FeedTypeRepository
|
||||||
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
|
from animaltrack.services.feed import FeedService, ValidationError
|
||||||
|
from animaltrack.web.templates import page
|
||||||
|
from animaltrack.web.templates.feed import feed_page
|
||||||
|
|
||||||
|
|
||||||
|
def get_feed_balance(db: Any, feed_type_code: str) -> int | None:
|
||||||
|
"""Get current feed balance for a feed type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
feed_type_code: Feed type code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Balance in kg, or None if no inventory record exists.
|
||||||
|
"""
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT balance_kg FROM feed_inventory WHERE feed_type_code = ?",
|
||||||
|
(feed_type_code,),
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def register_feed_routes(rt, app):
|
||||||
|
"""Register feed capture routes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rt: FastHTML route decorator.
|
||||||
|
app: FastHTML application instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@rt("/feed")
|
||||||
|
def feed_index(request: Request):
|
||||||
|
"""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(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
error_message,
|
||||||
|
selected_location_id=None,
|
||||||
|
selected_feed_type_code=None,
|
||||||
|
):
|
||||||
|
"""Render give form with error message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locations: List of active locations.
|
||||||
|
feed_types: List of active feed types.
|
||||||
|
error_message: Error message to display.
|
||||||
|
selected_location_id: Currently selected location.
|
||||||
|
selected_feed_type_code: Currently selected feed type.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with 422 status.
|
||||||
|
"""
|
||||||
|
return HTMLResponse(
|
||||||
|
content=str(
|
||||||
|
page(
|
||||||
|
feed_page(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
active_tab="give",
|
||||||
|
selected_location_id=selected_location_id,
|
||||||
|
selected_feed_type_code=selected_feed_type_code,
|
||||||
|
give_error=error_message,
|
||||||
|
),
|
||||||
|
title="Feed - AnimalTrack",
|
||||||
|
active_nav="feed",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _render_purchase_error(locations, feed_types, error_message):
|
||||||
|
"""Render purchase form with error message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locations: List of active locations.
|
||||||
|
feed_types: List of active feed types.
|
||||||
|
error_message: Error message to display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTMLResponse with 422 status.
|
||||||
|
"""
|
||||||
|
return HTMLResponse(
|
||||||
|
content=str(
|
||||||
|
page(
|
||||||
|
feed_page(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
active_tab="purchase",
|
||||||
|
purchase_error=error_message,
|
||||||
|
),
|
||||||
|
title="Feed - AnimalTrack",
|
||||||
|
active_nav="feed",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
status_code=422,
|
||||||
|
)
|
||||||
@@ -28,8 +28,11 @@ def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
|
|||||||
Title(title),
|
Title(title),
|
||||||
BottomNavStyles(),
|
BottomNavStyles(),
|
||||||
# Main content with bottom padding for fixed nav
|
# Main content with bottom padding for fixed nav
|
||||||
|
# hx-boost enables AJAX for all descendant forms/links
|
||||||
Div(
|
Div(
|
||||||
Container(content),
|
Container(content),
|
||||||
|
hx_boost="true",
|
||||||
|
hx_target="body",
|
||||||
cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100",
|
cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||||
),
|
),
|
||||||
BottomNav(active_id=active_nav),
|
BottomNav(active_id=active_nav),
|
||||||
|
|||||||
@@ -81,9 +81,8 @@ def egg_form(
|
|||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Eggs", type="submit", cls=ButtonT.primary),
|
Button("Record Eggs", type="submit", cls=ButtonT.primary),
|
||||||
# Form submission via HTMX
|
# Form submission via standard action/method (hx-boost handles AJAX)
|
||||||
hx_post="/actions/product-collected",
|
action="/actions/product-collected",
|
||||||
hx_target="body",
|
method="post",
|
||||||
hx_swap="innerHTML",
|
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|||||||
296
src/animaltrack/web/templates/feed.py
Normal file
296
src/animaltrack/web/templates/feed.py
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# ABOUTME: Templates for Feed Quick Capture forms.
|
||||||
|
# ABOUTME: Provides form components for recording feed given and purchases.
|
||||||
|
|
||||||
|
from fasthtml.common import H1, H2, A, Div, Form, Hidden, Li, Option, P, Ul
|
||||||
|
from monsterui.all import (
|
||||||
|
Button,
|
||||||
|
ButtonT,
|
||||||
|
LabelInput,
|
||||||
|
LabelSelect,
|
||||||
|
LabelTextArea,
|
||||||
|
TabContainer,
|
||||||
|
)
|
||||||
|
from ulid import ULID
|
||||||
|
|
||||||
|
from animaltrack.models.reference import FeedType, Location
|
||||||
|
|
||||||
|
|
||||||
|
def feed_page(
|
||||||
|
locations: list[Location],
|
||||||
|
feed_types: list[FeedType],
|
||||||
|
active_tab: str = "give",
|
||||||
|
selected_location_id: str | None = None,
|
||||||
|
selected_feed_type_code: str | None = None,
|
||||||
|
default_amount_kg: int | None = None,
|
||||||
|
give_error: str | None = None,
|
||||||
|
purchase_error: str | None = None,
|
||||||
|
balance_warning: str | None = None,
|
||||||
|
):
|
||||||
|
"""Create the Feed Quick Capture page with tabbed forms.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locations: List of active locations for the dropdown.
|
||||||
|
feed_types: List of active feed types for the dropdown.
|
||||||
|
active_tab: Which tab is active ('give' or 'purchase').
|
||||||
|
selected_location_id: Pre-selected location ID (sticks after give).
|
||||||
|
selected_feed_type_code: Pre-selected feed type code (sticks after give).
|
||||||
|
default_amount_kg: Default amount for give form (from feed type).
|
||||||
|
give_error: Error message for give form.
|
||||||
|
purchase_error: Error message for purchase form.
|
||||||
|
balance_warning: Warning about negative inventory balance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Page content with tabbed forms.
|
||||||
|
"""
|
||||||
|
give_active = active_tab == "give"
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
H1("Feed", cls="text-2xl font-bold mb-6"),
|
||||||
|
# Tab navigation
|
||||||
|
TabContainer(
|
||||||
|
Li(
|
||||||
|
A(
|
||||||
|
"Give Feed",
|
||||||
|
href="#",
|
||||||
|
cls="uk-active" if give_active else "",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Li(
|
||||||
|
A(
|
||||||
|
"Purchase Feed",
|
||||||
|
href="#",
|
||||||
|
cls="" if give_active else "uk-active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
uk_switcher="connect: #feed-forms; animation: uk-animation-fade",
|
||||||
|
alt=True,
|
||||||
|
),
|
||||||
|
# Tab content
|
||||||
|
Ul(id="feed-forms", cls="uk-switcher mt-4")(
|
||||||
|
Li(
|
||||||
|
give_feed_form(
|
||||||
|
locations,
|
||||||
|
feed_types,
|
||||||
|
selected_location_id=selected_location_id,
|
||||||
|
selected_feed_type_code=selected_feed_type_code,
|
||||||
|
default_amount_kg=default_amount_kg,
|
||||||
|
error=give_error,
|
||||||
|
balance_warning=balance_warning,
|
||||||
|
),
|
||||||
|
cls="uk-active" if give_active else "",
|
||||||
|
),
|
||||||
|
Li(
|
||||||
|
purchase_feed_form(feed_types, error=purchase_error),
|
||||||
|
cls="" if give_active else "uk-active",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
cls="p-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def give_feed_form(
|
||||||
|
locations: list[Location],
|
||||||
|
feed_types: list[FeedType],
|
||||||
|
selected_location_id: str | None = None,
|
||||||
|
selected_feed_type_code: str | None = None,
|
||||||
|
default_amount_kg: int | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
balance_warning: str | None = None,
|
||||||
|
) -> Form:
|
||||||
|
"""Create the Give Feed form.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
locations: List of active locations.
|
||||||
|
feed_types: List of active feed types.
|
||||||
|
selected_location_id: Pre-selected location ID.
|
||||||
|
selected_feed_type_code: Pre-selected feed type code.
|
||||||
|
default_amount_kg: Default value for amount field.
|
||||||
|
error: Error message to display.
|
||||||
|
balance_warning: Warning about negative balance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Form component for giving feed.
|
||||||
|
"""
|
||||||
|
# Build location options
|
||||||
|
location_options = [
|
||||||
|
Option(
|
||||||
|
loc.name,
|
||||||
|
value=loc.id,
|
||||||
|
selected=(loc.id == selected_location_id),
|
||||||
|
)
|
||||||
|
for loc in locations
|
||||||
|
]
|
||||||
|
if selected_location_id is None:
|
||||||
|
location_options.insert(
|
||||||
|
0, Option("Select a location...", value="", disabled=True, selected=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build feed type options
|
||||||
|
feed_type_options = [
|
||||||
|
Option(
|
||||||
|
ft.name,
|
||||||
|
value=ft.code,
|
||||||
|
selected=(ft.code == selected_feed_type_code),
|
||||||
|
)
|
||||||
|
for ft in feed_types
|
||||||
|
]
|
||||||
|
if selected_feed_type_code is None:
|
||||||
|
feed_type_options.insert(
|
||||||
|
0, Option("Select feed type...", value="", disabled=True, selected=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error display
|
||||||
|
error_component = None
|
||||||
|
if error:
|
||||||
|
error_component = Div(
|
||||||
|
P(error, cls="text-red-500 text-sm"),
|
||||||
|
cls="mb-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Warning display
|
||||||
|
warning_component = None
|
||||||
|
if balance_warning:
|
||||||
|
warning_component = Div(
|
||||||
|
P(balance_warning, cls="text-yellow-500 text-sm"),
|
||||||
|
cls="mb-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
H2("Give Feed", cls="text-xl font-bold mb-4"),
|
||||||
|
error_component,
|
||||||
|
warning_component,
|
||||||
|
# Location dropdown
|
||||||
|
LabelSelect(
|
||||||
|
*location_options,
|
||||||
|
label="Location",
|
||||||
|
id="location_id",
|
||||||
|
name="location_id",
|
||||||
|
),
|
||||||
|
# Feed type dropdown
|
||||||
|
LabelSelect(
|
||||||
|
*feed_type_options,
|
||||||
|
label="Feed Type",
|
||||||
|
id="feed_type_code",
|
||||||
|
name="feed_type_code",
|
||||||
|
),
|
||||||
|
# Amount input
|
||||||
|
LabelInput(
|
||||||
|
"Amount (kg)",
|
||||||
|
id="amount_kg",
|
||||||
|
name="amount_kg",
|
||||||
|
type="number",
|
||||||
|
min="1",
|
||||||
|
step="1",
|
||||||
|
value=str(default_amount_kg) if default_amount_kg else "",
|
||||||
|
placeholder="Amount in kg",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
# Optional notes
|
||||||
|
LabelTextArea(
|
||||||
|
"Notes",
|
||||||
|
id="notes",
|
||||||
|
name="notes",
|
||||||
|
placeholder="Optional notes",
|
||||||
|
),
|
||||||
|
# Hidden nonce
|
||||||
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
|
# Submit button
|
||||||
|
Button("Record Feed Given", type="submit", cls=ButtonT.primary),
|
||||||
|
action="/actions/feed-given",
|
||||||
|
method="post",
|
||||||
|
cls="space-y-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def purchase_feed_form(
|
||||||
|
feed_types: list[FeedType],
|
||||||
|
error: str | None = None,
|
||||||
|
) -> Form:
|
||||||
|
"""Create the Purchase Feed form.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_types: List of active feed types.
|
||||||
|
error: Error message to display.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Form component for purchasing feed.
|
||||||
|
"""
|
||||||
|
# Build feed type options
|
||||||
|
feed_type_options = [Option(ft.name, value=ft.code) for ft in feed_types]
|
||||||
|
feed_type_options.insert(
|
||||||
|
0, Option("Select feed type...", value="", disabled=True, selected=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Error display
|
||||||
|
error_component = None
|
||||||
|
if error:
|
||||||
|
error_component = Div(
|
||||||
|
P(error, cls="text-red-500 text-sm"),
|
||||||
|
cls="mb-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Form(
|
||||||
|
H2("Purchase Feed", cls="text-xl font-bold mb-4"),
|
||||||
|
error_component,
|
||||||
|
# Feed type dropdown
|
||||||
|
LabelSelect(
|
||||||
|
*feed_type_options,
|
||||||
|
label="Feed Type",
|
||||||
|
id="purchase_feed_type_code",
|
||||||
|
name="feed_type_code",
|
||||||
|
),
|
||||||
|
# Bag size
|
||||||
|
LabelInput(
|
||||||
|
"Bag Size (kg)",
|
||||||
|
id="bag_size_kg",
|
||||||
|
name="bag_size_kg",
|
||||||
|
type="number",
|
||||||
|
min="1",
|
||||||
|
step="1",
|
||||||
|
value="20",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
# Bags count
|
||||||
|
LabelInput(
|
||||||
|
"Number of Bags",
|
||||||
|
id="bags_count",
|
||||||
|
name="bags_count",
|
||||||
|
type="number",
|
||||||
|
min="1",
|
||||||
|
step="1",
|
||||||
|
value="1",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
# Price per bag (cents)
|
||||||
|
LabelInput(
|
||||||
|
"Price per Bag (cents)",
|
||||||
|
id="bag_price_cents",
|
||||||
|
name="bag_price_cents",
|
||||||
|
type="number",
|
||||||
|
min="0",
|
||||||
|
step="1",
|
||||||
|
placeholder="e.g., 2400 for 24.00",
|
||||||
|
required=True,
|
||||||
|
),
|
||||||
|
# Optional vendor
|
||||||
|
LabelInput(
|
||||||
|
"Vendor",
|
||||||
|
id="vendor",
|
||||||
|
name="vendor",
|
||||||
|
placeholder="Optional vendor name",
|
||||||
|
),
|
||||||
|
# Optional notes
|
||||||
|
LabelTextArea(
|
||||||
|
"Notes",
|
||||||
|
id="purchase_notes",
|
||||||
|
name="notes",
|
||||||
|
placeholder="Optional notes",
|
||||||
|
),
|
||||||
|
# Hidden nonce
|
||||||
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
|
# Submit button
|
||||||
|
Button("Record Purchase", type="submit", cls=ButtonT.primary),
|
||||||
|
action="/actions/feed-purchased",
|
||||||
|
method="post",
|
||||||
|
cls="space-y-4",
|
||||||
|
)
|
||||||
362
tests/test_web_feed.py
Normal file
362
tests/test_web_feed.py
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
# ABOUTME: Tests for Feed Quick Capture web routes.
|
||||||
|
# ABOUTME: Covers GET /feed form rendering and POST /actions/feed-given, /actions/feed-purchased.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from animaltrack.events.payloads import FeedPurchasedPayload
|
||||||
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.projections import ProjectionRegistry
|
||||||
|
from animaltrack.projections.feed import FeedInventoryProjection
|
||||||
|
from animaltrack.services.feed import FeedService
|
||||||
|
|
||||||
|
|
||||||
|
def make_test_settings(
|
||||||
|
csrf_secret: str = "test-secret",
|
||||||
|
trusted_proxy_ips: str = "127.0.0.1",
|
||||||
|
dev_mode: bool = True,
|
||||||
|
):
|
||||||
|
"""Create Settings for testing by setting env vars temporarily."""
|
||||||
|
from animaltrack.config import Settings
|
||||||
|
|
||||||
|
old_env = os.environ.copy()
|
||||||
|
try:
|
||||||
|
os.environ["CSRF_SECRET"] = csrf_secret
|
||||||
|
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
|
||||||
|
os.environ["DEV_MODE"] = str(dev_mode).lower()
|
||||||
|
return Settings()
|
||||||
|
finally:
|
||||||
|
os.environ.clear()
|
||||||
|
os.environ.update(old_env)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(seeded_db):
|
||||||
|
"""Create a test client for the app."""
|
||||||
|
from animaltrack.web.app import create_app
|
||||||
|
|
||||||
|
settings = make_test_settings(trusted_proxy_ips="testclient")
|
||||||
|
app, rt = create_app(settings=settings, db=seeded_db)
|
||||||
|
return TestClient(app, raise_server_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def location_strip1_id(seeded_db):
|
||||||
|
"""Get Strip 1 location ID from seeded data."""
|
||||||
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feed_service(seeded_db):
|
||||||
|
"""Create a FeedService for testing."""
|
||||||
|
event_store = EventStore(seeded_db)
|
||||||
|
registry = ProjectionRegistry()
|
||||||
|
registry.register(FeedInventoryProjection(seeded_db))
|
||||||
|
return FeedService(seeded_db, event_store, registry)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def feed_purchase_in_db(seeded_db, feed_service):
|
||||||
|
"""Create a feed purchase so give_feed can work."""
|
||||||
|
payload = FeedPurchasedPayload(
|
||||||
|
feed_type_code="layer",
|
||||||
|
bag_size_kg=20,
|
||||||
|
bags_count=5,
|
||||||
|
bag_price_cents=2400,
|
||||||
|
)
|
||||||
|
ts_utc = int(time.time() * 1000) - 86400000 # 1 day ago
|
||||||
|
feed_service.purchase_feed(payload, ts_utc, "test_user")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedFormRendering:
|
||||||
|
"""Tests for GET /feed form rendering."""
|
||||||
|
|
||||||
|
def test_feed_page_renders(self, client):
|
||||||
|
"""GET /feed returns 200."""
|
||||||
|
resp = client.get("/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_feed_page_shows_tabs(self, client):
|
||||||
|
"""Feed page shows both Give Feed and Purchase Feed tabs."""
|
||||||
|
resp = client.get("/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Give Feed" in resp.text or "give" in resp.text.lower()
|
||||||
|
assert "Purchase" in resp.text or "purchase" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_give_feed_form_has_fields(self, client):
|
||||||
|
"""Give feed form has required fields."""
|
||||||
|
resp = client.get("/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Check for location, feed type, amount fields
|
||||||
|
assert 'name="location_id"' in resp.text or 'id="location_id"' in resp.text
|
||||||
|
assert 'name="feed_type_code"' in resp.text or 'id="feed_type_code"' in resp.text
|
||||||
|
assert 'name="amount_kg"' in resp.text or 'id="amount_kg"' in resp.text
|
||||||
|
|
||||||
|
def test_locations_in_dropdown(self, client):
|
||||||
|
"""Active locations appear in dropdown."""
|
||||||
|
resp = client.get("/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Strip 1" in resp.text
|
||||||
|
assert "Strip 2" in resp.text
|
||||||
|
|
||||||
|
def test_feed_types_in_dropdown(self, client):
|
||||||
|
"""Active feed types appear in dropdown."""
|
||||||
|
resp = client.get("/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Seeded feed types include 'layer'
|
||||||
|
assert "layer" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedGiven:
|
||||||
|
"""Tests for POST /actions/feed-given."""
|
||||||
|
|
||||||
|
def test_give_feed_creates_event(
|
||||||
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""POST creates FeedGiven event."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in [200, 302, 303]
|
||||||
|
|
||||||
|
# Verify event was created in database
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT type, payload FROM events WHERE type = 'FeedGiven' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert event_row is not None
|
||||||
|
assert event_row[0] == "FeedGiven"
|
||||||
|
|
||||||
|
def test_give_feed_validation_amount_zero(
|
||||||
|
self, client, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""amount_kg=0 returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "0",
|
||||||
|
"nonce": "test-nonce-feed-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_give_feed_validation_amount_negative(
|
||||||
|
self, client, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""amount_kg=-1 returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "-1",
|
||||||
|
"nonce": "test-nonce-feed-3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_give_feed_validation_missing_location(self, client, feed_purchase_in_db):
|
||||||
|
"""Missing location_id returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_give_feed_validation_missing_feed_type(
|
||||||
|
self, client, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""Missing feed_type_code returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-5",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_give_feed_blocked_without_purchase(self, client, location_strip1_id):
|
||||||
|
"""Cannot give feed if no purchase exists for this feed type."""
|
||||||
|
# Don't use feed_purchase_in_db fixture
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-6",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
assert "purchase" in resp.text.lower()
|
||||||
|
|
||||||
|
def test_give_feed_location_sticks(
|
||||||
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""After successful POST, returned form shows same location selected."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-7",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# The location should be pre-selected
|
||||||
|
assert "selected" in resp.text and location_strip1_id in resp.text
|
||||||
|
|
||||||
|
def test_give_feed_type_sticks(
|
||||||
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""After successful POST, returned form shows same feed type selected."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# The feed type should be pre-selected
|
||||||
|
assert "layer" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedPurchased:
|
||||||
|
"""Tests for POST /actions/feed-purchased."""
|
||||||
|
|
||||||
|
def test_purchase_feed_creates_event(self, client, seeded_db):
|
||||||
|
"""POST creates FeedPurchased event."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"bag_size_kg": "20",
|
||||||
|
"bags_count": "2",
|
||||||
|
"bag_price_cents": "2400",
|
||||||
|
"nonce": "test-nonce-purchase-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code in [200, 302, 303]
|
||||||
|
|
||||||
|
# Verify event was created in database
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT type, payload FROM events WHERE type = 'FeedPurchased' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert event_row is not None
|
||||||
|
assert event_row[0] == "FeedPurchased"
|
||||||
|
|
||||||
|
def test_purchase_feed_validation_bag_size_zero(self, client):
|
||||||
|
"""bag_size_kg=0 returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"bag_size_kg": "0",
|
||||||
|
"bags_count": "2",
|
||||||
|
"bag_price_cents": "2400",
|
||||||
|
"nonce": "test-nonce-purchase-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_purchase_feed_validation_bags_count_zero(self, client):
|
||||||
|
"""bags_count=0 returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"bag_size_kg": "20",
|
||||||
|
"bags_count": "0",
|
||||||
|
"bag_price_cents": "2400",
|
||||||
|
"nonce": "test-nonce-purchase-3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_purchase_feed_validation_missing_feed_type(self, client):
|
||||||
|
"""Missing feed_type_code returns 422."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"bag_size_kg": "20",
|
||||||
|
"bags_count": "2",
|
||||||
|
"bag_price_cents": "2400",
|
||||||
|
"nonce": "test-nonce-purchase-4",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
def test_purchase_enables_give(self, client, seeded_db, location_strip1_id):
|
||||||
|
"""After purchasing, give feed works."""
|
||||||
|
# First purchase
|
||||||
|
resp1 = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"bag_size_kg": "20",
|
||||||
|
"bags_count": "2",
|
||||||
|
"bag_price_cents": "2400",
|
||||||
|
"nonce": "test-nonce-purchase-5",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp1.status_code in [200, 302, 303]
|
||||||
|
|
||||||
|
# Then give should work
|
||||||
|
resp2 = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-feed-after-purchase",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp2.status_code in [200, 302, 303]
|
||||||
|
|
||||||
|
|
||||||
|
class TestInventoryWarning:
|
||||||
|
"""Tests for inventory balance warnings."""
|
||||||
|
|
||||||
|
def test_negative_balance_shows_warning(
|
||||||
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""Warning shown when give would result in negative balance."""
|
||||||
|
# Purchase was 5 bags x 20kg = 100kg
|
||||||
|
# Give 150kg (more than available) - should show warning but allow
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "150",
|
||||||
|
"nonce": "test-nonce-warning-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Should succeed but with warning
|
||||||
|
assert resp.status_code in [200, 302, 303]
|
||||||
|
# The response should contain a warning about negative inventory
|
||||||
|
assert "warning" in resp.text.lower() or "negative" in resp.text.lower()
|
||||||
Reference in New Issue
Block a user