Add recent events and stats to eggs, feed, and move forms
All checks were successful
Deploy / deploy (push) Successful in 2m40s
All checks were successful
Deploy / deploy (push) Successful in 2m40s
- Create recent_events.py helper for rendering event lists with humanized timestamps and deleted event styling (line-through + opacity) - Query events with ORDER BY ts_utc DESC to show newest first - Join event_tombstones to detect deleted events - Fix move form to read animal_ids (not resolved_ids) from entity_refs - Fix feed purchase format to use total_kg from entity_refs - Use hx_get with #event-panel-content target for slide-over panel - Add days-since-last stats for move and feed forms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ from fasthtml.common import APIRouter, add_toast, to_xml
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from animaltrack.events import PRODUCT_COLLECTED, PRODUCT_SOLD
|
||||||
from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload
|
from animaltrack.events.payloads import ProductCollectedPayload, ProductSoldPayload
|
||||||
from animaltrack.events.store import EventStore
|
from animaltrack.events.store import EventStore
|
||||||
from animaltrack.models.reference import UserDefault
|
from animaltrack.models.reference import UserDefault
|
||||||
@@ -26,6 +27,9 @@ from animaltrack.services.products import ProductService, ValidationError
|
|||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page
|
||||||
from animaltrack.web.templates.eggs import eggs_page
|
from animaltrack.web.templates.eggs import eggs_page
|
||||||
|
|
||||||
|
# 30 days in milliseconds
|
||||||
|
THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
def _parse_ts_utc(form_value: str | None) -> int:
|
def _parse_ts_utc(form_value: str | None) -> int:
|
||||||
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||||
@@ -90,6 +94,132 @@ def _get_sellable_products(db):
|
|||||||
return [p for p in all_products if p.active and p.sellable]
|
return [p for p in all_products if p.active and p.sellable]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_events(db: Any, event_type: str, limit: int = 10):
|
||||||
|
"""Get recent events of a type, most recent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
event_type: Event type string (e.g., PRODUCT_COLLECTED).
|
||||||
|
limit: Maximum number of events to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (Event, is_deleted) tuples, most recent first.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
|
||||||
|
# Query newest events first with tombstone status
|
||||||
|
query = """
|
||||||
|
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version,
|
||||||
|
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
WHERE e.type = ?
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
rows = db.execute(query, (event_type, limit)).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
Event(
|
||||||
|
id=row[0],
|
||||||
|
type=row[1],
|
||||||
|
ts_utc=row[2],
|
||||||
|
actor=row[3],
|
||||||
|
entity_refs=json.loads(row[4]),
|
||||||
|
payload=json.loads(row[5]),
|
||||||
|
version=row[6],
|
||||||
|
),
|
||||||
|
bool(row[7]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_eggs_per_day(db: Any, now_ms: int) -> float | None:
|
||||||
|
"""Calculate eggs per day over 30-day window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Eggs per day average, or None if no data.
|
||||||
|
"""
|
||||||
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
|
event_store = EventStore(db)
|
||||||
|
events = event_store.list_events(
|
||||||
|
event_type=PRODUCT_COLLECTED,
|
||||||
|
since_utc=window_start,
|
||||||
|
until_utc=now_ms,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_eggs = 0
|
||||||
|
for event in events:
|
||||||
|
product_code = event.entity_refs.get("product_code", "")
|
||||||
|
if product_code.startswith("egg."):
|
||||||
|
total_eggs += event.entity_refs.get("quantity", 0)
|
||||||
|
|
||||||
|
if total_eggs == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return total_eggs / 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
|
||||||
|
"""Calculate sales statistics over 30-day window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'total_qty' and 'total_cents', or None if no data.
|
||||||
|
"""
|
||||||
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
|
event_store = EventStore(db)
|
||||||
|
events = event_store.list_events(
|
||||||
|
event_type=PRODUCT_SOLD,
|
||||||
|
since_utc=window_start,
|
||||||
|
until_utc=now_ms,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_qty = 0
|
||||||
|
total_cents = 0
|
||||||
|
for event in events:
|
||||||
|
total_qty += event.entity_refs.get("quantity", 0)
|
||||||
|
total_cents += event.entity_refs.get("total_price_cents", 0)
|
||||||
|
|
||||||
|
return {"total_qty": total_qty, "total_cents": total_cents}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_eggs_display_data(db: Any, locations: list) -> dict:
|
||||||
|
"""Get all display data for eggs page (events and stats).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
locations: List of Location objects for name lookup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with harvest_events, sell_events, eggs_per_day, sales_stats, location_names.
|
||||||
|
"""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return {
|
||||||
|
"harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10),
|
||||||
|
"sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10),
|
||||||
|
"eggs_per_day": _get_eggs_per_day(db, now_ms),
|
||||||
|
"sales_stats": _get_sales_stats(db, now_ms),
|
||||||
|
"location_names": {loc.id: loc.name for loc in locations},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ar("/")
|
@ar("/")
|
||||||
def egg_index(request: Request):
|
def egg_index(request: Request):
|
||||||
"""GET / - Eggs page with Harvest/Sell tabs."""
|
"""GET / - Eggs page with Harvest/Sell tabs."""
|
||||||
@@ -115,6 +245,9 @@ def egg_index(request: Request):
|
|||||||
if defaults:
|
if defaults:
|
||||||
selected_location_id = defaults.location_id
|
selected_location_id = defaults.location_id
|
||||||
|
|
||||||
|
# Get recent events and stats
|
||||||
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
|
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
eggs_page(
|
eggs_page(
|
||||||
@@ -124,6 +257,7 @@ def egg_index(request: Request):
|
|||||||
selected_location_id=selected_location_id,
|
selected_location_id=selected_location_id,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
@@ -152,19 +286,21 @@ async def product_collected(request: Request, session):
|
|||||||
|
|
||||||
# Validate location_id
|
# Validate location_id
|
||||||
if not location_id:
|
if not location_id:
|
||||||
return _render_harvest_error(request, locations, products, None, "Please select a location")
|
return _render_harvest_error(
|
||||||
|
request, db, locations, products, None, "Please select a location"
|
||||||
|
)
|
||||||
|
|
||||||
# Validate quantity
|
# Validate quantity
|
||||||
try:
|
try:
|
||||||
quantity = int(quantity_str)
|
quantity = int(quantity_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, locations, products, location_id, "Quantity must be a number"
|
request, db, locations, products, location_id, "Quantity must be a number"
|
||||||
)
|
)
|
||||||
|
|
||||||
if quantity < 1:
|
if quantity < 1:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, locations, products, location_id, "Quantity must be at least 1"
|
request, db, locations, products, location_id, "Quantity must be at least 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get timestamp - use provided or current (supports backdating)
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
@@ -175,7 +311,7 @@ async def product_collected(request: Request, session):
|
|||||||
|
|
||||||
if not resolved_ids:
|
if not resolved_ids:
|
||||||
return _render_harvest_error(
|
return _render_harvest_error(
|
||||||
request, locations, products, location_id, "No ducks at this location"
|
request, db, locations, products, location_id, "No ducks at this location"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create product service
|
# Create product service
|
||||||
@@ -208,7 +344,7 @@ async def product_collected(request: Request, session):
|
|||||||
route="/actions/product-collected",
|
route="/actions/product-collected",
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_harvest_error(request, locations, products, location_id, str(e))
|
return _render_harvest_error(request, db, locations, products, location_id, str(e))
|
||||||
|
|
||||||
# Save user defaults (only if user exists in database)
|
# Save user defaults (only if user exists in database)
|
||||||
if UserRepository(db).get(actor):
|
if UserRepository(db).get(actor):
|
||||||
@@ -228,6 +364,9 @@ async def product_collected(request: Request, session):
|
|||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get display data (includes newly created event)
|
||||||
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
|
|
||||||
# Success: re-render form with location sticking, qty cleared
|
# Success: re-render form with location sticking, qty cleared
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -238,6 +377,7 @@ async def product_collected(request: Request, session):
|
|||||||
selected_location_id=location_id,
|
selected_location_id=location_id,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
@@ -268,19 +408,19 @@ async def product_sold(request: Request, session):
|
|||||||
|
|
||||||
# Validate product_code
|
# Validate product_code
|
||||||
if not product_code:
|
if not product_code:
|
||||||
return _render_sell_error(request, locations, products, None, "Please select a product")
|
return _render_sell_error(request, db, locations, products, None, "Please select a product")
|
||||||
|
|
||||||
# Validate quantity
|
# Validate quantity
|
||||||
try:
|
try:
|
||||||
quantity = int(quantity_str)
|
quantity = int(quantity_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, locations, products, product_code, "Quantity must be a number"
|
request, db, locations, products, product_code, "Quantity must be a number"
|
||||||
)
|
)
|
||||||
|
|
||||||
if quantity < 1:
|
if quantity < 1:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, locations, products, product_code, "Quantity must be at least 1"
|
request, db, locations, products, product_code, "Quantity must be at least 1"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate total_price_cents
|
# Validate total_price_cents
|
||||||
@@ -288,12 +428,12 @@ async def product_sold(request: Request, session):
|
|||||||
total_price_cents = int(total_price_str)
|
total_price_cents = int(total_price_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, locations, products, product_code, "Total price must be a number"
|
request, db, locations, products, product_code, "Total price must be a number"
|
||||||
)
|
)
|
||||||
|
|
||||||
if total_price_cents < 0:
|
if total_price_cents < 0:
|
||||||
return _render_sell_error(
|
return _render_sell_error(
|
||||||
request, locations, products, product_code, "Total price cannot be negative"
|
request, db, locations, products, product_code, "Total price cannot be negative"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get timestamp - use provided or current (supports backdating)
|
# Get timestamp - use provided or current (supports backdating)
|
||||||
@@ -326,7 +466,7 @@ async def product_sold(request: Request, session):
|
|||||||
route="/actions/product-sold",
|
route="/actions/product-sold",
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_sell_error(request, locations, products, product_code, str(e))
|
return _render_sell_error(request, db, locations, products, product_code, str(e))
|
||||||
|
|
||||||
# Add success toast with link to event
|
# Add success toast with link to event
|
||||||
add_toast(
|
add_toast(
|
||||||
@@ -335,6 +475,9 @@ async def product_sold(request: Request, session):
|
|||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get display data (includes newly created event)
|
||||||
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
|
|
||||||
# Success: re-render form with product sticking
|
# Success: re-render form with product sticking
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -345,17 +488,19 @@ async def product_sold(request: Request, session):
|
|||||||
selected_product_code=product_code,
|
selected_product_code=product_code,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_harvest_error(request, locations, products, selected_location_id, error_message):
|
def _render_harvest_error(request, db, locations, products, selected_location_id, error_message):
|
||||||
"""Render harvest form with error message.
|
"""Render harvest form with error message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The HTTP request.
|
request: The HTTP request.
|
||||||
|
db: Database connection.
|
||||||
locations: List of active locations.
|
locations: List of active locations.
|
||||||
products: List of sellable products.
|
products: List of sellable products.
|
||||||
selected_location_id: Currently selected location.
|
selected_location_id: Currently selected location.
|
||||||
@@ -364,6 +509,7 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with 422 status.
|
HTMLResponse with 422 status.
|
||||||
"""
|
"""
|
||||||
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
@@ -376,6 +522,7 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
|
|||||||
harvest_error=error_message,
|
harvest_error=error_message,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
@@ -385,11 +532,12 @@ def _render_harvest_error(request, locations, products, selected_location_id, er
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_sell_error(request, locations, products, selected_product_code, error_message):
|
def _render_sell_error(request, db, locations, products, selected_product_code, error_message):
|
||||||
"""Render sell form with error message.
|
"""Render sell form with error message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The HTTP request.
|
request: The HTTP request.
|
||||||
|
db: Database connection.
|
||||||
locations: List of active locations.
|
locations: List of active locations.
|
||||||
products: List of sellable products.
|
products: List of sellable products.
|
||||||
selected_product_code: Currently selected product code.
|
selected_product_code: Currently selected product code.
|
||||||
@@ -398,6 +546,7 @@ def _render_sell_error(request, locations, products, selected_product_code, erro
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with 422 status.
|
HTMLResponse with 422 status.
|
||||||
"""
|
"""
|
||||||
|
display_data = _get_eggs_display_data(db, locations)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
@@ -410,6 +559,7 @@ def _render_sell_error(request, locations, products, selected_product_code, erro
|
|||||||
sell_error=error_message,
|
sell_error=error_message,
|
||||||
harvest_action=product_collected,
|
harvest_action=product_collected,
|
||||||
sell_action=product_sold,
|
sell_action=product_sold,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Eggs - AnimalTrack",
|
title="Eggs - AnimalTrack",
|
||||||
active_nav="eggs",
|
active_nav="eggs",
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from fasthtml.common import APIRouter, add_toast, to_xml
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from animaltrack.events import FEED_GIVEN, FEED_PURCHASED
|
||||||
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
|
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
|
||||||
from animaltrack.events.store import EventStore
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.models.reference import UserDefault
|
from animaltrack.models.reference import UserDefault
|
||||||
from animaltrack.projections import EventLogProjection, ProjectionRegistry
|
from animaltrack.projections import EventLogProjection, ProjectionRegistry
|
||||||
from animaltrack.projections.feed import FeedInventoryProjection
|
from animaltrack.projections.feed import FeedInventoryProjection
|
||||||
@@ -23,6 +25,9 @@ from animaltrack.services.feed import FeedService, ValidationError
|
|||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page
|
||||||
from animaltrack.web.templates.feed import feed_page
|
from animaltrack.web.templates.feed import feed_page
|
||||||
|
|
||||||
|
# 30 days in milliseconds
|
||||||
|
THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
def _parse_ts_utc(form_value: str | None) -> int:
|
def _parse_ts_utc(form_value: str | None) -> int:
|
||||||
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||||
@@ -64,6 +69,164 @@ 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 _get_recent_events(db: Any, event_type: str, limit: int = 10):
|
||||||
|
"""Get recent events of a type, most recent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
event_type: Event type string.
|
||||||
|
limit: Maximum number of events to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (Event, is_deleted) tuples, most recent first.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Query newest events first with tombstone status
|
||||||
|
query = """
|
||||||
|
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version,
|
||||||
|
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
WHERE e.type = ?
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
rows = db.execute(query, (event_type, limit)).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
Event(
|
||||||
|
id=row[0],
|
||||||
|
type=row[1],
|
||||||
|
ts_utc=row[2],
|
||||||
|
actor=row[3],
|
||||||
|
entity_refs=json.loads(row[4]),
|
||||||
|
payload=json.loads(row[5]),
|
||||||
|
version=row[6],
|
||||||
|
),
|
||||||
|
bool(row[7]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_feed_per_bird_per_day(db: Any, now_ms: int) -> float | None:
|
||||||
|
"""Calculate feed consumption per bird per day over 30-day window.
|
||||||
|
|
||||||
|
Uses global bird-days across all locations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Feed consumption in grams per bird per day, or None if no data.
|
||||||
|
"""
|
||||||
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
|
|
||||||
|
# Get total feed given in window (all locations)
|
||||||
|
event_store = EventStore(db)
|
||||||
|
events = event_store.list_events(
|
||||||
|
event_type=FEED_GIVEN,
|
||||||
|
since_utc=window_start,
|
||||||
|
until_utc=now_ms,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_kg = sum(e.entity_refs.get("amount_kg", 0) for e in events)
|
||||||
|
if total_kg == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_g = total_kg * 1000
|
||||||
|
|
||||||
|
# Get total bird-days across all locations
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(SUM(
|
||||||
|
MIN(COALESCE(ali.end_utc, :window_end), :window_end) -
|
||||||
|
MAX(ali.start_utc, :window_start)
|
||||||
|
), 0) as total_ms
|
||||||
|
FROM animal_location_intervals ali
|
||||||
|
JOIN animal_registry ar ON ali.animal_id = ar.animal_id
|
||||||
|
WHERE ali.start_utc < :window_end
|
||||||
|
AND (ali.end_utc IS NULL OR ali.end_utc > :window_start)
|
||||||
|
AND ar.status = 'alive'
|
||||||
|
""",
|
||||||
|
{"window_start": window_start, "window_end": now_ms},
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
total_ms = row[0] if row else 0
|
||||||
|
ms_per_day = 24 * 60 * 60 * 1000
|
||||||
|
bird_days = total_ms // ms_per_day if total_ms else 0
|
||||||
|
|
||||||
|
if bird_days == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return total_g / bird_days
|
||||||
|
|
||||||
|
|
||||||
|
def _get_purchase_stats(db: Any, now_ms: int) -> dict | None:
|
||||||
|
"""Calculate purchase statistics over 30-day window.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'total_kg' and 'avg_price_per_kg_cents', or None if no data.
|
||||||
|
"""
|
||||||
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
|
event_store = EventStore(db)
|
||||||
|
events = event_store.list_events(
|
||||||
|
event_type=FEED_PURCHASED,
|
||||||
|
since_utc=window_start,
|
||||||
|
until_utc=now_ms,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_kg = 0
|
||||||
|
total_cost_cents = 0
|
||||||
|
for event in events:
|
||||||
|
bag_size = event.entity_refs.get("bag_size_kg", 0)
|
||||||
|
bags_count = event.entity_refs.get("bags_count", 0)
|
||||||
|
bag_price_cents = event.entity_refs.get("bag_price_cents", 0)
|
||||||
|
total_kg += bag_size * bags_count
|
||||||
|
total_cost_cents += bag_price_cents * bags_count
|
||||||
|
|
||||||
|
if total_kg == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
avg_price_per_kg_cents = total_cost_cents / total_kg
|
||||||
|
|
||||||
|
return {"total_kg": total_kg, "avg_price_per_kg_cents": avg_price_per_kg_cents}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_feed_display_data(db: Any, locations: list, feed_types: list) -> dict:
|
||||||
|
"""Get all display data for feed page (events and stats).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
locations: List of Location objects for name lookup.
|
||||||
|
feed_types: List of FeedType objects for name lookup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with display data for feed page.
|
||||||
|
"""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return {
|
||||||
|
"give_events": _get_recent_events(db, FEED_GIVEN, limit=10),
|
||||||
|
"purchase_events": _get_recent_events(db, FEED_PURCHASED, limit=10),
|
||||||
|
"feed_per_bird_per_day_g": _get_feed_per_bird_per_day(db, now_ms),
|
||||||
|
"purchase_stats": _get_purchase_stats(db, now_ms),
|
||||||
|
"location_names": {loc.id: loc.name for loc in locations},
|
||||||
|
"feed_type_names": {ft.code: ft.name for ft in feed_types},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ar("/feed")
|
@ar("/feed")
|
||||||
def feed_index(request: Request):
|
def feed_index(request: Request):
|
||||||
"""GET /feed - Feed Quick Capture page."""
|
"""GET /feed - Feed Quick Capture page."""
|
||||||
@@ -90,6 +253,9 @@ def feed_index(request: Request):
|
|||||||
selected_feed_type_code = defaults.feed_type_code
|
selected_feed_type_code = defaults.feed_type_code
|
||||||
default_amount_kg = defaults.amount_kg
|
default_amount_kg = defaults.amount_kg
|
||||||
|
|
||||||
|
# Get recent events and stats
|
||||||
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
|
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
feed_page(
|
feed_page(
|
||||||
@@ -101,6 +267,7 @@ def feed_index(request: Request):
|
|||||||
default_amount_kg=default_amount_kg,
|
default_amount_kg=default_amount_kg,
|
||||||
give_action=feed_given,
|
give_action=feed_given,
|
||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
@@ -135,6 +302,7 @@ async def feed_given(request: Request, session):
|
|||||||
if not location_id:
|
if not location_id:
|
||||||
return _render_give_error(
|
return _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Please select a location",
|
"Please select a location",
|
||||||
@@ -146,6 +314,7 @@ async def feed_given(request: Request, session):
|
|||||||
if not feed_type_code:
|
if not feed_type_code:
|
||||||
return _render_give_error(
|
return _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Please select a feed type",
|
"Please select a feed type",
|
||||||
@@ -159,6 +328,7 @@ async def feed_given(request: Request, session):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_give_error(
|
return _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Amount must be a number",
|
"Amount must be a number",
|
||||||
@@ -169,6 +339,7 @@ async def feed_given(request: Request, session):
|
|||||||
if amount_kg < 1:
|
if amount_kg < 1:
|
||||||
return _render_give_error(
|
return _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Amount must be at least 1 kg",
|
"Amount must be at least 1 kg",
|
||||||
@@ -211,6 +382,7 @@ async def feed_given(request: Request, session):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_give_error(
|
return _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
str(e),
|
str(e),
|
||||||
@@ -244,6 +416,9 @@ async def feed_given(request: Request, session):
|
|||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get display data (includes newly created event)
|
||||||
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
|
|
||||||
# Success: re-render form with location/type sticking, amount reset
|
# Success: re-render form with location/type sticking, amount reset
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -257,6 +432,7 @@ async def feed_given(request: Request, session):
|
|||||||
balance_warning=balance_warning,
|
balance_warning=balance_warning,
|
||||||
give_action=feed_given,
|
give_action=feed_given,
|
||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
@@ -286,6 +462,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
if not feed_type_code:
|
if not feed_type_code:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Please select a feed type",
|
"Please select a feed type",
|
||||||
@@ -297,6 +474,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Bag size must be a number",
|
"Bag size must be a number",
|
||||||
@@ -305,6 +483,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
if bag_size_kg < 1:
|
if bag_size_kg < 1:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Bag size must be at least 1 kg",
|
"Bag size must be at least 1 kg",
|
||||||
@@ -316,6 +495,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Bags count must be a number",
|
"Bags count must be a number",
|
||||||
@@ -324,6 +504,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
if bags_count < 1:
|
if bags_count < 1:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Bags count must be at least 1",
|
"Bags count must be at least 1",
|
||||||
@@ -336,6 +517,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Price must be a number",
|
"Price must be a number",
|
||||||
@@ -344,6 +526,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
if bag_price_cents < 0:
|
if bag_price_cents < 0:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
"Price cannot be negative",
|
"Price cannot be negative",
|
||||||
@@ -385,6 +568,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_purchase_error(
|
return _render_purchase_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
str(e),
|
str(e),
|
||||||
@@ -400,6 +584,9 @@ async def feed_purchased(request: Request, session):
|
|||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get display data (includes newly created event)
|
||||||
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
|
|
||||||
# Success: re-render form with fields cleared
|
# Success: re-render form with fields cleared
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -409,6 +596,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
active_tab="purchase",
|
active_tab="purchase",
|
||||||
give_action=feed_given,
|
give_action=feed_given,
|
||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
@@ -417,6 +605,7 @@ async def feed_purchased(request: Request, session):
|
|||||||
|
|
||||||
def _render_give_error(
|
def _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
db,
|
||||||
locations,
|
locations,
|
||||||
feed_types,
|
feed_types,
|
||||||
error_message,
|
error_message,
|
||||||
@@ -427,6 +616,7 @@ def _render_give_error(
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The Starlette request object.
|
request: The Starlette request object.
|
||||||
|
db: Database connection.
|
||||||
locations: List of active locations.
|
locations: List of active locations.
|
||||||
feed_types: List of active feed types.
|
feed_types: List of active feed types.
|
||||||
error_message: Error message to display.
|
error_message: Error message to display.
|
||||||
@@ -436,6 +626,7 @@ def _render_give_error(
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with 422 status.
|
HTMLResponse with 422 status.
|
||||||
"""
|
"""
|
||||||
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
@@ -449,6 +640,7 @@ def _render_give_error(
|
|||||||
give_error=error_message,
|
give_error=error_message,
|
||||||
give_action=feed_given,
|
give_action=feed_given,
|
||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
@@ -458,11 +650,12 @@ def _render_give_error(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _render_purchase_error(request, locations, feed_types, error_message):
|
def _render_purchase_error(request, db, locations, feed_types, error_message):
|
||||||
"""Render purchase form with error message.
|
"""Render purchase form with error message.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: The Starlette request object.
|
request: The Starlette request object.
|
||||||
|
db: Database connection.
|
||||||
locations: List of active locations.
|
locations: List of active locations.
|
||||||
feed_types: List of active feed types.
|
feed_types: List of active feed types.
|
||||||
error_message: Error message to display.
|
error_message: Error message to display.
|
||||||
@@ -470,6 +663,7 @@ def _render_purchase_error(request, locations, feed_types, error_message):
|
|||||||
Returns:
|
Returns:
|
||||||
HTMLResponse with 422 status.
|
HTMLResponse with 422 status.
|
||||||
"""
|
"""
|
||||||
|
display_data = _get_feed_display_data(db, locations, feed_types)
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
@@ -481,6 +675,7 @@ def _render_purchase_error(request, locations, feed_types, error_message):
|
|||||||
purchase_error=error_message,
|
purchase_error=error_message,
|
||||||
give_action=feed_given,
|
give_action=feed_given,
|
||||||
purchase_action=feed_purchased,
|
purchase_action=feed_purchased,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Feed - AnimalTrack",
|
title="Feed - AnimalTrack",
|
||||||
active_nav="feed",
|
active_nav="feed",
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ from fasthtml.common import APIRouter, add_toast, to_xml
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from animaltrack.events import ANIMAL_MOVED
|
||||||
from animaltrack.events.payloads import AnimalMovedPayload
|
from animaltrack.events.payloads import AnimalMovedPayload
|
||||||
from animaltrack.events.store import EventStore
|
from animaltrack.events.store import EventStore
|
||||||
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections import EventLogProjection, ProjectionRegistry
|
from animaltrack.projections import EventLogProjection, ProjectionRegistry
|
||||||
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||||
from animaltrack.projections.event_animals import EventAnimalsProjection
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||||
@@ -24,6 +26,9 @@ from animaltrack.services.animal import AnimalService, ValidationError
|
|||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page
|
||||||
from animaltrack.web.templates.move import diff_panel, move_form
|
from animaltrack.web.templates.move import diff_panel, move_form
|
||||||
|
|
||||||
|
# Milliseconds per day
|
||||||
|
MS_PER_DAY = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
|
||||||
def _parse_ts_utc(form_value: str | None) -> int:
|
def _parse_ts_utc(form_value: str | None) -> int:
|
||||||
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
"""Parse ts_utc from form, defaulting to current time if empty or zero.
|
||||||
@@ -44,6 +49,91 @@ def _parse_ts_utc(form_value: str | None) -> int:
|
|||||||
return int(time.time() * 1000)
|
return int(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_move_events(db: Any, limit: int = 10):
|
||||||
|
"""Get recent AnimalMoved events, most recent first.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
limit: Maximum number of events to return.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of (Event, is_deleted) tuples, most recent first.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Query newest events first with tombstone status
|
||||||
|
query = """
|
||||||
|
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version,
|
||||||
|
CASE WHEN t.target_event_id IS NOT NULL THEN 1 ELSE 0 END as is_deleted
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
WHERE e.type = ?
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
rows = db.execute(query, (ANIMAL_MOVED, limit)).fetchall()
|
||||||
|
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
Event(
|
||||||
|
id=row[0],
|
||||||
|
type=row[1],
|
||||||
|
ts_utc=row[2],
|
||||||
|
actor=row[3],
|
||||||
|
entity_refs=json.loads(row[4]),
|
||||||
|
payload=json.loads(row[5]),
|
||||||
|
version=row[6],
|
||||||
|
),
|
||||||
|
bool(row[7]),
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_days_since_last_move(db: Any, now_ms: int) -> int | None:
|
||||||
|
"""Calculate days since the last move event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of days since last move, or None if no moves exist.
|
||||||
|
"""
|
||||||
|
# Query the most recent move event (newest first)
|
||||||
|
query = """
|
||||||
|
SELECT ts_utc FROM events
|
||||||
|
WHERE type = ?
|
||||||
|
ORDER BY ts_utc DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
row = db.execute(query, (ANIMAL_MOVED,)).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
|
||||||
|
diff_ms = now_ms - row[0]
|
||||||
|
return diff_ms // MS_PER_DAY
|
||||||
|
|
||||||
|
|
||||||
|
def _get_move_display_data(db: Any, locations: list) -> dict:
|
||||||
|
"""Get all display data for move page (events and stats).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
locations: List of Location objects for name lookup.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with recent_events, days_since_last_move, location_names.
|
||||||
|
"""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
return {
|
||||||
|
"recent_events": _get_recent_move_events(db, limit=10),
|
||||||
|
"days_since_last_move": _get_days_since_last_move(db, now_ms),
|
||||||
|
"location_names": {loc.id: loc.name for loc in locations},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# APIRouter for multi-file route organization
|
# APIRouter for multi-file route organization
|
||||||
ar = APIRouter()
|
ar = APIRouter()
|
||||||
|
|
||||||
@@ -115,6 +205,9 @@ def move_index(request: Request):
|
|||||||
animal_repo = AnimalRepository(db)
|
animal_repo = AnimalRepository(db)
|
||||||
animals = animal_repo.get_by_ids(resolved_ids)
|
animals = animal_repo.get_by_ids(resolved_ids)
|
||||||
|
|
||||||
|
# Get recent events and stats
|
||||||
|
display_data = _get_move_display_data(db, locations)
|
||||||
|
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
move_form(
|
move_form(
|
||||||
@@ -128,6 +221,7 @@ def move_index(request: Request):
|
|||||||
from_location_name=from_location_name,
|
from_location_name=from_location_name,
|
||||||
action=animal_move,
|
action=animal_move,
|
||||||
animals=animals,
|
animals=animals,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Move - AnimalTrack",
|
title="Move - AnimalTrack",
|
||||||
active_nav="move",
|
active_nav="move",
|
||||||
@@ -298,12 +392,16 @@ async def animal_move(request: Request, session):
|
|||||||
"success",
|
"success",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get display data for fresh form
|
||||||
|
display_data = _get_move_display_data(db, locations)
|
||||||
|
|
||||||
# Success: re-render fresh form (nothing sticks per spec)
|
# Success: re-render fresh form (nothing sticks per spec)
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
move_form(
|
move_form(
|
||||||
locations,
|
locations,
|
||||||
action=animal_move,
|
action=animal_move,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Move - AnimalTrack",
|
title="Move - AnimalTrack",
|
||||||
active_nav="move",
|
active_nav="move",
|
||||||
@@ -339,6 +437,9 @@ def _render_error_form(request, db, locations, filter_str, error_message):
|
|||||||
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
|
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
|
||||||
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
|
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
|
||||||
|
|
||||||
|
# Get display data for recent events and stats
|
||||||
|
display_data = _get_move_display_data(db, locations)
|
||||||
|
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
@@ -354,6 +455,7 @@ def _render_error_form(request, db, locations, filter_str, error_message):
|
|||||||
from_location_name=from_location_name,
|
from_location_name=from_location_name,
|
||||||
error=error_message,
|
error=error_message,
|
||||||
action=animal_move,
|
action=animal_move,
|
||||||
|
**display_data,
|
||||||
),
|
),
|
||||||
title="Move - AnimalTrack",
|
title="Move - AnimalTrack",
|
||||||
active_nav="move",
|
active_nav="move",
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ from monsterui.all import (
|
|||||||
)
|
)
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.models.reference import Location, Product
|
from animaltrack.models.reference import Location, Product
|
||||||
from animaltrack.web.templates.actions import event_datetime_field
|
from animaltrack.web.templates.actions import event_datetime_field
|
||||||
|
from animaltrack.web.templates.recent_events import recent_events_section
|
||||||
|
|
||||||
|
|
||||||
def eggs_page(
|
def eggs_page(
|
||||||
@@ -29,6 +31,11 @@ def eggs_page(
|
|||||||
sell_error: str | None = None,
|
sell_error: str | None = None,
|
||||||
harvest_action: Callable[..., Any] | str = "/actions/product-collected",
|
harvest_action: Callable[..., Any] | str = "/actions/product-collected",
|
||||||
sell_action: Callable[..., Any] | str = "/actions/product-sold",
|
sell_action: Callable[..., Any] | str = "/actions/product-sold",
|
||||||
|
harvest_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
sell_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
eggs_per_day: float | None = None,
|
||||||
|
sales_stats: dict | None = None,
|
||||||
|
location_names: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
"""Create the Eggs page with tabbed forms.
|
"""Create the Eggs page with tabbed forms.
|
||||||
|
|
||||||
@@ -42,11 +49,18 @@ def eggs_page(
|
|||||||
sell_error: Error message for sell form.
|
sell_error: Error message for sell form.
|
||||||
harvest_action: Route function or URL for harvest form.
|
harvest_action: Route function or URL for harvest form.
|
||||||
sell_action: Route function or URL for sell form.
|
sell_action: Route function or URL for sell form.
|
||||||
|
harvest_events: Recent ProductCollected events (most recent first).
|
||||||
|
sell_events: Recent ProductSold events (most recent first).
|
||||||
|
eggs_per_day: 30-day average eggs per day.
|
||||||
|
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
|
||||||
|
location_names: Dict mapping location_id to location name for display.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Page content with tabbed forms.
|
Page content with tabbed forms.
|
||||||
"""
|
"""
|
||||||
harvest_active = active_tab == "harvest"
|
harvest_active = active_tab == "harvest"
|
||||||
|
if location_names is None:
|
||||||
|
location_names = {}
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
H1("Eggs", cls="text-2xl font-bold mb-6"),
|
H1("Eggs", cls="text-2xl font-bold mb-6"),
|
||||||
@@ -65,6 +79,9 @@ def eggs_page(
|
|||||||
selected_location_id=selected_location_id,
|
selected_location_id=selected_location_id,
|
||||||
error=harvest_error,
|
error=harvest_error,
|
||||||
action=harvest_action,
|
action=harvest_action,
|
||||||
|
recent_events=harvest_events,
|
||||||
|
eggs_per_day=eggs_per_day,
|
||||||
|
location_names=location_names,
|
||||||
),
|
),
|
||||||
cls="uk-active" if harvest_active else None,
|
cls="uk-active" if harvest_active else None,
|
||||||
),
|
),
|
||||||
@@ -74,6 +91,8 @@ def eggs_page(
|
|||||||
selected_product_code=selected_product_code,
|
selected_product_code=selected_product_code,
|
||||||
error=sell_error,
|
error=sell_error,
|
||||||
action=sell_action,
|
action=sell_action,
|
||||||
|
recent_events=sell_events,
|
||||||
|
sales_stats=sales_stats,
|
||||||
),
|
),
|
||||||
cls=None if harvest_active else "uk-active",
|
cls=None if harvest_active else "uk-active",
|
||||||
),
|
),
|
||||||
@@ -87,7 +106,10 @@ def harvest_form(
|
|||||||
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",
|
action: Callable[..., Any] | str = "/actions/product-collected",
|
||||||
) -> Form:
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
eggs_per_day: float | None = None,
|
||||||
|
location_names: dict[str, str] | None = None,
|
||||||
|
) -> Div:
|
||||||
"""Create the Harvest form for egg collection.
|
"""Create the Harvest form for egg collection.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -95,10 +117,18 @@ def harvest_form(
|
|||||||
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.
|
action: Route function or URL string for form submission.
|
||||||
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
|
eggs_per_day: 30-day average eggs per day.
|
||||||
|
location_names: Dict mapping location_id to location name for display.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for recording egg harvests.
|
Div containing form and recent events section.
|
||||||
"""
|
"""
|
||||||
|
if recent_events is None:
|
||||||
|
recent_events = []
|
||||||
|
if location_names is None:
|
||||||
|
location_names = {}
|
||||||
|
|
||||||
# Build location options
|
# Build location options
|
||||||
location_options = [
|
location_options = [
|
||||||
Option(
|
Option(
|
||||||
@@ -123,7 +153,19 @@ def harvest_form(
|
|||||||
cls="mb-4",
|
cls="mb-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
return Form(
|
# Format function for harvest events
|
||||||
|
def format_harvest_event(event: Event) -> tuple[str, str]:
|
||||||
|
quantity = event.entity_refs.get("quantity", 0)
|
||||||
|
loc_id = event.entity_refs.get("location_id", "")
|
||||||
|
loc_name = location_names.get(loc_id, "Unknown")
|
||||||
|
return f"{quantity} eggs from {loc_name}", event.id
|
||||||
|
|
||||||
|
# Build stats text
|
||||||
|
stat_text = None
|
||||||
|
if eggs_per_day is not None:
|
||||||
|
stat_text = f"{eggs_per_day:.1f} eggs/day (30-day avg)"
|
||||||
|
|
||||||
|
form = Form(
|
||||||
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
@@ -164,13 +206,25 @@ def harvest_form(
|
|||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
form,
|
||||||
|
recent_events_section(
|
||||||
|
title="Recent Harvests",
|
||||||
|
events=recent_events,
|
||||||
|
format_fn=format_harvest_event,
|
||||||
|
stat_text=stat_text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sell_form(
|
def sell_form(
|
||||||
products: list[Product],
|
products: list[Product],
|
||||||
selected_product_code: str | None = "egg.duck",
|
selected_product_code: str | None = "egg.duck",
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/product-sold",
|
action: Callable[..., Any] | str = "/actions/product-sold",
|
||||||
) -> Form:
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
sales_stats: dict | None = None,
|
||||||
|
) -> Div:
|
||||||
"""Create the Sell form for recording sales.
|
"""Create the Sell form for recording sales.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -178,10 +232,17 @@ def sell_form(
|
|||||||
selected_product_code: Pre-selected product code (defaults to egg.duck).
|
selected_product_code: Pre-selected product code (defaults to egg.duck).
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
|
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for recording product sales.
|
Div containing form and recent events section.
|
||||||
"""
|
"""
|
||||||
|
if recent_events is None:
|
||||||
|
recent_events = []
|
||||||
|
if sales_stats is None:
|
||||||
|
sales_stats = {}
|
||||||
|
|
||||||
# Build product options
|
# Build product options
|
||||||
product_options = [
|
product_options = [
|
||||||
Option(
|
Option(
|
||||||
@@ -206,7 +267,23 @@ def sell_form(
|
|||||||
cls="mb-4",
|
cls="mb-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
return Form(
|
# Format function for sell events
|
||||||
|
def format_sell_event(event: Event) -> tuple[str, str]:
|
||||||
|
quantity = event.entity_refs.get("quantity", 0)
|
||||||
|
product_code = event.entity_refs.get("product_code", "")
|
||||||
|
total_cents = event.entity_refs.get("total_price_cents", 0)
|
||||||
|
total_eur = total_cents / 100
|
||||||
|
return f"{quantity} {product_code} for €{total_eur:.2f}", event.id
|
||||||
|
|
||||||
|
# Build stats text
|
||||||
|
stat_text = None
|
||||||
|
total_qty = sales_stats.get("total_qty")
|
||||||
|
total_cents = sales_stats.get("total_cents")
|
||||||
|
if total_qty is not None and total_cents is not None:
|
||||||
|
total_eur = total_cents / 100
|
||||||
|
stat_text = f"{total_qty} sold for €{total_eur:.2f} (30-day total)"
|
||||||
|
|
||||||
|
form = Form(
|
||||||
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
@@ -266,6 +343,16 @@ def sell_form(
|
|||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
form,
|
||||||
|
recent_events_section(
|
||||||
|
title="Recent Sales",
|
||||||
|
events=recent_events,
|
||||||
|
format_fn=format_sell_event,
|
||||||
|
stat_text=stat_text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Keep the old function name for backwards compatibility
|
# Keep the old function name for backwards compatibility
|
||||||
def egg_form(
|
def egg_form(
|
||||||
@@ -274,8 +361,8 @@ def egg_form(
|
|||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/product-collected",
|
action: Callable[..., Any] | str = "/actions/product-collected",
|
||||||
) -> Div:
|
) -> Div:
|
||||||
"""Legacy function - returns harvest form wrapped in a Div.
|
"""Legacy function - returns harvest form.
|
||||||
|
|
||||||
Deprecated: Use eggs_page() for the full tabbed interface.
|
Deprecated: Use eggs_page() for the full tabbed interface.
|
||||||
"""
|
"""
|
||||||
return Div(harvest_form(locations, selected_location_id, error, action))
|
return harvest_form(locations, selected_location_id, error, action)
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ from monsterui.all import (
|
|||||||
)
|
)
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.models.reference import FeedType, Location
|
from animaltrack.models.reference import FeedType, Location
|
||||||
from animaltrack.web.templates.actions import event_datetime_field
|
from animaltrack.web.templates.actions import event_datetime_field
|
||||||
|
from animaltrack.web.templates.recent_events import recent_events_section
|
||||||
|
|
||||||
|
|
||||||
def feed_page(
|
def feed_page(
|
||||||
@@ -31,6 +33,12 @@ def feed_page(
|
|||||||
balance_warning: str | None = None,
|
balance_warning: str | None = None,
|
||||||
give_action: Callable[..., Any] | str = "/actions/feed-given",
|
give_action: Callable[..., Any] | str = "/actions/feed-given",
|
||||||
purchase_action: Callable[..., Any] | str = "/actions/feed-purchased",
|
purchase_action: Callable[..., Any] | str = "/actions/feed-purchased",
|
||||||
|
give_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
purchase_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
feed_per_bird_per_day_g: float | None = None,
|
||||||
|
purchase_stats: dict | None = None,
|
||||||
|
location_names: dict[str, str] | None = None,
|
||||||
|
feed_type_names: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
"""Create the Feed Quick Capture page with tabbed forms.
|
"""Create the Feed Quick Capture page with tabbed forms.
|
||||||
|
|
||||||
@@ -46,11 +54,21 @@ def feed_page(
|
|||||||
balance_warning: Warning about negative inventory balance.
|
balance_warning: Warning about negative inventory balance.
|
||||||
give_action: Route function or URL for give feed form.
|
give_action: Route function or URL for give feed form.
|
||||||
purchase_action: Route function or URL for purchase feed form.
|
purchase_action: Route function or URL for purchase feed form.
|
||||||
|
give_events: Recent FeedGiven events (most recent first).
|
||||||
|
purchase_events: Recent FeedPurchased events (most recent first).
|
||||||
|
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
|
||||||
|
purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'.
|
||||||
|
location_names: Dict mapping location_id to location name.
|
||||||
|
feed_type_names: Dict mapping feed_type_code to feed type name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Page content with tabbed forms.
|
Page content with tabbed forms.
|
||||||
"""
|
"""
|
||||||
give_active = active_tab == "give"
|
give_active = active_tab == "give"
|
||||||
|
if location_names is None:
|
||||||
|
location_names = {}
|
||||||
|
if feed_type_names is None:
|
||||||
|
feed_type_names = {}
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
H1("Feed", cls="text-2xl font-bold mb-6"),
|
H1("Feed", cls="text-2xl font-bold mb-6"),
|
||||||
@@ -73,11 +91,22 @@ def feed_page(
|
|||||||
error=give_error,
|
error=give_error,
|
||||||
balance_warning=balance_warning,
|
balance_warning=balance_warning,
|
||||||
action=give_action,
|
action=give_action,
|
||||||
|
recent_events=give_events,
|
||||||
|
feed_per_bird_per_day_g=feed_per_bird_per_day_g,
|
||||||
|
location_names=location_names,
|
||||||
|
feed_type_names=feed_type_names,
|
||||||
),
|
),
|
||||||
cls="uk-active" if give_active else None,
|
cls="uk-active" if give_active else None,
|
||||||
),
|
),
|
||||||
Li(
|
Li(
|
||||||
purchase_feed_form(feed_types, error=purchase_error, action=purchase_action),
|
purchase_feed_form(
|
||||||
|
feed_types,
|
||||||
|
error=purchase_error,
|
||||||
|
action=purchase_action,
|
||||||
|
recent_events=purchase_events,
|
||||||
|
purchase_stats=purchase_stats,
|
||||||
|
feed_type_names=feed_type_names,
|
||||||
|
),
|
||||||
cls=None if give_active else "uk-active",
|
cls=None if give_active else "uk-active",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -94,7 +123,11 @@ def give_feed_form(
|
|||||||
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",
|
action: Callable[..., Any] | str = "/actions/feed-given",
|
||||||
) -> Form:
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
feed_per_bird_per_day_g: float | None = None,
|
||||||
|
location_names: dict[str, str] | None = None,
|
||||||
|
feed_type_names: dict[str, str] | None = None,
|
||||||
|
) -> Div:
|
||||||
"""Create the Give Feed form.
|
"""Create the Give Feed form.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -106,10 +139,21 @@ def give_feed_form(
|
|||||||
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.
|
action: Route function or URL for form submission.
|
||||||
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
|
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
|
||||||
|
location_names: Dict mapping location_id to location name.
|
||||||
|
feed_type_names: Dict mapping feed_type_code to feed type name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for giving feed.
|
Div containing form and recent events section.
|
||||||
"""
|
"""
|
||||||
|
if recent_events is None:
|
||||||
|
recent_events = []
|
||||||
|
if location_names is None:
|
||||||
|
location_names = {}
|
||||||
|
if feed_type_names is None:
|
||||||
|
feed_type_names = {}
|
||||||
|
|
||||||
# Build location options
|
# Build location options
|
||||||
location_options = [
|
location_options = [
|
||||||
Option(
|
Option(
|
||||||
@@ -154,7 +198,21 @@ def give_feed_form(
|
|||||||
cls="mb-4",
|
cls="mb-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
return Form(
|
# Format function for feed given events
|
||||||
|
def format_give_event(event: Event) -> tuple[str, str]:
|
||||||
|
amount_kg = event.entity_refs.get("amount_kg", 0)
|
||||||
|
loc_id = event.entity_refs.get("location_id", "")
|
||||||
|
feed_code = event.entity_refs.get("feed_type_code", "")
|
||||||
|
loc_name = location_names.get(loc_id, "Unknown")
|
||||||
|
feed_name = feed_type_names.get(feed_code, feed_code)
|
||||||
|
return f"{amount_kg}kg {feed_name} to {loc_name}", event.id
|
||||||
|
|
||||||
|
# Build stats text
|
||||||
|
stat_text = None
|
||||||
|
if feed_per_bird_per_day_g is not None:
|
||||||
|
stat_text = f"{feed_per_bird_per_day_g:.1f} g/bird/day (30-day avg)"
|
||||||
|
|
||||||
|
form = Form(
|
||||||
H2("Give Feed", cls="text-xl font-bold mb-4"),
|
H2("Give Feed", cls="text-xl font-bold mb-4"),
|
||||||
error_component,
|
error_component,
|
||||||
warning_component,
|
warning_component,
|
||||||
@@ -200,22 +258,45 @@ def give_feed_form(
|
|||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
form,
|
||||||
|
recent_events_section(
|
||||||
|
title="Recent Feed Given",
|
||||||
|
events=recent_events,
|
||||||
|
format_fn=format_give_event,
|
||||||
|
stat_text=stat_text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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",
|
action: Callable[..., Any] | str = "/actions/feed-purchased",
|
||||||
) -> Form:
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
purchase_stats: dict | None = None,
|
||||||
|
feed_type_names: dict[str, str] | None = None,
|
||||||
|
) -> Div:
|
||||||
"""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.
|
action: Route function or URL for form submission.
|
||||||
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
|
purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'.
|
||||||
|
feed_type_names: Dict mapping feed_type_code to feed type name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for purchasing feed.
|
Div containing form and recent events section.
|
||||||
"""
|
"""
|
||||||
|
if recent_events is None:
|
||||||
|
recent_events = []
|
||||||
|
if purchase_stats is None:
|
||||||
|
purchase_stats = {}
|
||||||
|
if feed_type_names is None:
|
||||||
|
feed_type_names = {}
|
||||||
|
|
||||||
# Build feed type options
|
# Build feed type options
|
||||||
feed_type_options = [Option(ft.name, value=ft.code) for ft in feed_types]
|
feed_type_options = [Option(ft.name, value=ft.code) for ft in feed_types]
|
||||||
feed_type_options.insert(
|
feed_type_options.insert(
|
||||||
@@ -230,7 +311,26 @@ def purchase_feed_form(
|
|||||||
cls="mb-4",
|
cls="mb-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
return Form(
|
# Format function for purchase events
|
||||||
|
# Note: entity_refs has total_kg, payload has bag details
|
||||||
|
def format_purchase_event(event: Event) -> tuple[str, str]:
|
||||||
|
total_kg = event.entity_refs.get("total_kg", 0)
|
||||||
|
price_per_kg = event.entity_refs.get("price_per_kg_cents", 0)
|
||||||
|
total_cents = total_kg * price_per_kg
|
||||||
|
total_eur = total_cents / 100
|
||||||
|
feed_code = event.entity_refs.get("feed_type_code", "")
|
||||||
|
feed_name = feed_type_names.get(feed_code, feed_code)
|
||||||
|
return f"{total_kg}kg {feed_name} for €{total_eur:.2f}", event.id
|
||||||
|
|
||||||
|
# Build stats text
|
||||||
|
stat_text = None
|
||||||
|
total_kg = purchase_stats.get("total_kg")
|
||||||
|
avg_price = purchase_stats.get("avg_price_per_kg_cents")
|
||||||
|
if total_kg is not None and avg_price is not None:
|
||||||
|
avg_eur = avg_price / 100
|
||||||
|
stat_text = f"{total_kg}kg purchased, €{avg_eur:.2f}/kg avg (30-day)"
|
||||||
|
|
||||||
|
form = Form(
|
||||||
H2("Purchase Feed", cls="text-xl font-bold mb-4"),
|
H2("Purchase Feed", cls="text-xl font-bold mb-4"),
|
||||||
error_component,
|
error_component,
|
||||||
# Feed type dropdown - using raw Select to fix value handling
|
# Feed type dropdown - using raw Select to fix value handling
|
||||||
@@ -301,3 +401,13 @@ def purchase_feed_form(
|
|||||||
method="post",
|
method="post",
|
||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
form,
|
||||||
|
recent_events_section(
|
||||||
|
title="Recent Purchases",
|
||||||
|
events=recent_events,
|
||||||
|
format_fn=format_purchase_event,
|
||||||
|
stat_text=stat_text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ def recent_events_section(events: list[dict[str, Any]]) -> Div:
|
|||||||
),
|
),
|
||||||
href=f"/events/{event.get('event_id')}",
|
href=f"/events/{event.get('event_id')}",
|
||||||
hx_get=f"/events/{event.get('event_id')}",
|
hx_get=f"/events/{event.get('event_id')}",
|
||||||
hx_target="#event-panel",
|
hx_target="#event-panel-content",
|
||||||
hx_swap="innerHTML",
|
hx_swap="innerHTML",
|
||||||
),
|
),
|
||||||
cls="py-1",
|
cls="py-1",
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
|
|||||||
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
from monsterui.all import Alert, AlertT, Button, ButtonT, LabelInput, LabelSelect, LabelTextArea
|
||||||
from ulid import ULID
|
from ulid import ULID
|
||||||
|
|
||||||
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.models.reference import Location
|
from animaltrack.models.reference import Location
|
||||||
from animaltrack.selection.validation import SelectionDiff
|
from animaltrack.selection.validation import SelectionDiff
|
||||||
from animaltrack.web.templates.actions import event_datetime_field
|
from animaltrack.web.templates.actions import event_datetime_field
|
||||||
|
from animaltrack.web.templates.recent_events import recent_events_section
|
||||||
|
|
||||||
|
|
||||||
def move_form(
|
def move_form(
|
||||||
@@ -25,7 +27,10 @@ def move_form(
|
|||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/animal-move",
|
action: Callable[..., Any] | str = "/actions/animal-move",
|
||||||
animals: list | None = None,
|
animals: list | None = None,
|
||||||
) -> Form:
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
|
days_since_last_move: int | None = None,
|
||||||
|
location_names: dict[str, str] | None = None,
|
||||||
|
) -> Div:
|
||||||
"""Create the Move Animals form.
|
"""Create the Move Animals form.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -40,9 +45,12 @@ def move_form(
|
|||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
animals: List of AnimalListItem for checkbox selection (optional).
|
animals: List of AnimalListItem for checkbox selection (optional).
|
||||||
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
|
days_since_last_move: Number of days since the last move event.
|
||||||
|
location_names: Dict mapping location_id to location name.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for moving animals.
|
Div containing form and recent events section.
|
||||||
"""
|
"""
|
||||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||||
|
|
||||||
@@ -50,6 +58,10 @@ def move_form(
|
|||||||
resolved_ids = []
|
resolved_ids = []
|
||||||
if animals is None:
|
if animals is None:
|
||||||
animals = []
|
animals = []
|
||||||
|
if recent_events is None:
|
||||||
|
recent_events = []
|
||||||
|
if location_names is None:
|
||||||
|
location_names = {}
|
||||||
|
|
||||||
# Build destination location options (exclude from_location if set)
|
# Build destination location options (exclude from_location if set)
|
||||||
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
|
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
|
||||||
@@ -103,7 +115,25 @@ def move_form(
|
|||||||
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
|
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
|
||||||
]
|
]
|
||||||
|
|
||||||
return Form(
|
# Format function for move events
|
||||||
|
# Note: entity_refs stores animal_ids, not resolved_ids
|
||||||
|
def format_move_event(event: Event) -> tuple[str, str]:
|
||||||
|
to_loc_id = event.entity_refs.get("to_location_id", "")
|
||||||
|
to_loc_name = location_names.get(to_loc_id, "Unknown")
|
||||||
|
count = len(event.entity_refs.get("animal_ids", []))
|
||||||
|
return f"{count} animals to {to_loc_name}", event.id
|
||||||
|
|
||||||
|
# Build stats text
|
||||||
|
stat_text = None
|
||||||
|
if days_since_last_move is not None:
|
||||||
|
if days_since_last_move == 0:
|
||||||
|
stat_text = "Last move: today"
|
||||||
|
elif days_since_last_move == 1:
|
||||||
|
stat_text = "Last move: yesterday"
|
||||||
|
else:
|
||||||
|
stat_text = f"Last move: {days_since_last_move} days ago"
|
||||||
|
|
||||||
|
form = Form(
|
||||||
H2("Move Animals", cls="text-xl font-bold mb-4"),
|
H2("Move Animals", cls="text-xl font-bold mb-4"),
|
||||||
# Error message if present
|
# Error message if present
|
||||||
error_component,
|
error_component,
|
||||||
@@ -152,6 +182,16 @@ def move_form(
|
|||||||
cls="space-y-4",
|
cls="space-y-4",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
form,
|
||||||
|
recent_events_section(
|
||||||
|
title="Recent Moves",
|
||||||
|
events=recent_events,
|
||||||
|
format_fn=format_move_event,
|
||||||
|
stat_text=stat_text,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def diff_panel(
|
def diff_panel(
|
||||||
diff: SelectionDiff,
|
diff: SelectionDiff,
|
||||||
|
|||||||
119
src/animaltrack/web/templates/recent_events.py
Normal file
119
src/animaltrack/web/templates/recent_events.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
# ABOUTME: Helper components for displaying recent events on forms.
|
||||||
|
# ABOUTME: Provides event list rendering with humanized timestamps and links.
|
||||||
|
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fasthtml.common import Div, P, Span
|
||||||
|
|
||||||
|
from animaltrack.models.events import Event
|
||||||
|
|
||||||
|
# Milliseconds per unit
|
||||||
|
MS_PER_SECOND = 1000
|
||||||
|
MS_PER_MINUTE = 60 * MS_PER_SECOND
|
||||||
|
MS_PER_HOUR = 60 * MS_PER_MINUTE
|
||||||
|
MS_PER_DAY = 24 * MS_PER_HOUR
|
||||||
|
|
||||||
|
|
||||||
|
def humanize_time_ago(ts_utc: int) -> str:
|
||||||
|
"""Convert a timestamp to a human-readable relative time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ts_utc: Timestamp in milliseconds since epoch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Human-readable string like "2h ago", "3 days ago", "just now".
|
||||||
|
"""
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
diff_ms = now_ms - ts_utc
|
||||||
|
|
||||||
|
if diff_ms < 0:
|
||||||
|
return "in the future"
|
||||||
|
|
||||||
|
if diff_ms < MS_PER_MINUTE:
|
||||||
|
return "just now"
|
||||||
|
|
||||||
|
if diff_ms < MS_PER_HOUR:
|
||||||
|
minutes = diff_ms // MS_PER_MINUTE
|
||||||
|
return f"{minutes}m ago"
|
||||||
|
|
||||||
|
if diff_ms < MS_PER_DAY:
|
||||||
|
hours = diff_ms // MS_PER_HOUR
|
||||||
|
return f"{hours}h ago"
|
||||||
|
|
||||||
|
days = diff_ms // MS_PER_DAY
|
||||||
|
if days == 1:
|
||||||
|
return "1 day ago"
|
||||||
|
return f"{days} days ago"
|
||||||
|
|
||||||
|
|
||||||
|
def recent_events_section(
|
||||||
|
title: str,
|
||||||
|
events: list[tuple[Event, bool]],
|
||||||
|
format_fn: Callable[[Event], tuple[str, str]],
|
||||||
|
stat_text: str | None = None,
|
||||||
|
) -> Div:
|
||||||
|
"""Render a section with stats and recent events.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Section title (e.g., "Recent Harvests").
|
||||||
|
events: List of (Event, is_deleted) tuples, most recent first.
|
||||||
|
format_fn: Function that takes an Event and returns (description, event_id).
|
||||||
|
Description is the text to display, event_id for linking.
|
||||||
|
stat_text: Optional statistics text to show above the event list.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div containing the stats and event list.
|
||||||
|
"""
|
||||||
|
children: list[Any] = []
|
||||||
|
|
||||||
|
# Stats section
|
||||||
|
if stat_text:
|
||||||
|
children.append(
|
||||||
|
Div(
|
||||||
|
P(stat_text, cls="text-sm text-stone-600 dark:text-stone-400"),
|
||||||
|
cls="mb-3 p-2 bg-stone-50 dark:bg-stone-800 rounded",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Title
|
||||||
|
children.append(
|
||||||
|
P(
|
||||||
|
title,
|
||||||
|
cls="text-xs font-semibold text-stone-500 dark:text-stone-400 uppercase tracking-wide mb-2",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Event list
|
||||||
|
if not events:
|
||||||
|
children.append(
|
||||||
|
P("No recent events", cls="text-sm text-stone-400 dark:text-stone-500 italic")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
event_items = []
|
||||||
|
for event, is_deleted in events:
|
||||||
|
description, event_id = format_fn(event)
|
||||||
|
time_ago = humanize_time_ago(event.ts_utc)
|
||||||
|
|
||||||
|
# Apply deleted styling if tombstoned
|
||||||
|
deleted_cls = "line-through opacity-50" if is_deleted else ""
|
||||||
|
|
||||||
|
event_items.append(
|
||||||
|
Div(
|
||||||
|
Div(
|
||||||
|
Span(description, cls=f"flex-1 {deleted_cls}"),
|
||||||
|
Span(
|
||||||
|
time_ago, cls=f"text-stone-400 dark:text-stone-500 ml-2 {deleted_cls}"
|
||||||
|
),
|
||||||
|
cls="flex justify-between items-center",
|
||||||
|
),
|
||||||
|
cls="block text-sm py-1 px-2 rounded hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors cursor-pointer",
|
||||||
|
hx_get=f"/events/{event_id}",
|
||||||
|
hx_target="#event-panel-content",
|
||||||
|
hx_swap="innerHTML",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
children.append(Div(*event_items, cls="space-y-1"))
|
||||||
|
|
||||||
|
return Div(*children, cls="mt-6 pt-4 border-t border-stone-200 dark:border-stone-700")
|
||||||
@@ -211,3 +211,60 @@ class TestEggCollection:
|
|||||||
# The response should contain the form with the location pre-selected
|
# The response should contain the form with the location pre-selected
|
||||||
# Check for "selected" attribute on the option with our location_id
|
# Check for "selected" attribute on the option with our location_id
|
||||||
assert "selected" in resp.text and location_strip1_id in resp.text
|
assert "selected" in resp.text and location_strip1_id in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestEggsRecentEvents:
|
||||||
|
"""Tests for recent events display on eggs page."""
|
||||||
|
|
||||||
|
def test_harvest_tab_shows_recent_events_section(self, client):
|
||||||
|
"""Harvest tab shows Recent Harvests section."""
|
||||||
|
resp = client.get("/")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Recent Harvests" in resp.text
|
||||||
|
|
||||||
|
def test_sell_tab_shows_recent_events_section(self, client):
|
||||||
|
"""Sell tab shows Recent Sales section."""
|
||||||
|
resp = client.get("/?tab=sell")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Recent Sales" in resp.text
|
||||||
|
|
||||||
|
def test_harvest_event_appears_in_recent(
|
||||||
|
self, client, seeded_db, location_strip1_id, ducks_at_strip1
|
||||||
|
):
|
||||||
|
"""Newly created harvest event appears in recent events list."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/product-collected",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"quantity": "12",
|
||||||
|
"nonce": "test-nonce-recent-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Recent events should include the newly created event
|
||||||
|
# Check for event link pattern
|
||||||
|
assert "/events/" in resp.text
|
||||||
|
|
||||||
|
def test_harvest_event_links_to_detail(
|
||||||
|
self, client, seeded_db, location_strip1_id, ducks_at_strip1
|
||||||
|
):
|
||||||
|
"""Harvest events in recent list link to event detail page."""
|
||||||
|
# Create an event
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/product-collected",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"quantity": "8",
|
||||||
|
"nonce": "test-nonce-recent-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Get the event ID from DB
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT id FROM events WHERE type = 'ProductCollected' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
event_id = event_row[0]
|
||||||
|
|
||||||
|
# The response should contain a link to the event detail
|
||||||
|
assert f"/events/{event_id}" in resp.text
|
||||||
|
|||||||
@@ -360,3 +360,99 @@ class TestInventoryWarning:
|
|||||||
assert resp.status_code in [200, 302, 303]
|
assert resp.status_code in [200, 302, 303]
|
||||||
# The response should contain a warning about negative inventory
|
# The response should contain a warning about negative inventory
|
||||||
assert "warning" in resp.text.lower() or "negative" in resp.text.lower()
|
assert "warning" in resp.text.lower() or "negative" in resp.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedRecentEvents:
|
||||||
|
"""Tests for recent events display on feed page."""
|
||||||
|
|
||||||
|
def test_give_tab_shows_recent_events_section(self, client):
|
||||||
|
"""Give Feed tab shows Recent Feed Given section."""
|
||||||
|
resp = client.get("/feed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Recent Feed Given" in resp.text
|
||||||
|
|
||||||
|
def test_purchase_tab_shows_recent_events_section(self, client):
|
||||||
|
"""Purchase Feed tab shows Recent Purchases section."""
|
||||||
|
resp = client.get("/feed?tab=purchase")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Recent Purchases" in resp.text
|
||||||
|
|
||||||
|
def test_give_feed_event_appears_in_recent(
|
||||||
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""Newly created feed given event appears in recent events list."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-recent-feed-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Recent events should include the newly created event
|
||||||
|
assert "/events/" in resp.text
|
||||||
|
|
||||||
|
def test_give_feed_event_links_to_detail(
|
||||||
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
||||||
|
):
|
||||||
|
"""Feed given events in recent list link to event detail page."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-given",
|
||||||
|
data={
|
||||||
|
"location_id": location_strip1_id,
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"amount_kg": "5",
|
||||||
|
"nonce": "test-nonce-recent-feed-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Get the event ID from DB
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT id FROM events WHERE type = 'FeedGiven' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
event_id = event_row[0]
|
||||||
|
|
||||||
|
# The response should contain a link to the event detail
|
||||||
|
assert f"/events/{event_id}" in resp.text
|
||||||
|
|
||||||
|
def test_purchase_event_appears_in_recent(self, client, seeded_db):
|
||||||
|
"""Newly created purchase event appears in recent events list."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"bag_size_kg": "20",
|
||||||
|
"bags_count": "2",
|
||||||
|
"bag_price_euros": "24.00",
|
||||||
|
"nonce": "test-nonce-recent-purchase-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# The route returns purchase tab active after purchase
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "/events/" in resp.text
|
||||||
|
|
||||||
|
def test_purchase_event_links_to_detail(self, client, seeded_db):
|
||||||
|
"""Purchase events in recent list link to event detail page."""
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/feed-purchased",
|
||||||
|
data={
|
||||||
|
"feed_type_code": "layer",
|
||||||
|
"bag_size_kg": "20",
|
||||||
|
"bags_count": "2",
|
||||||
|
"bag_price_euros": "24.00",
|
||||||
|
"nonce": "test-nonce-recent-purchase-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Get the event ID from DB
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT id FROM events WHERE type = 'FeedPurchased' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
event_id = event_row[0]
|
||||||
|
|
||||||
|
# The response should contain a link to the event detail
|
||||||
|
assert f"/events/{event_id}" in resp.text
|
||||||
|
|||||||
@@ -472,3 +472,116 @@ class TestMoveAnimalMismatch:
|
|||||||
payload = json.loads(event_row[0])
|
payload = json.loads(event_row[0])
|
||||||
# Should have moved 3 animals (5 original - 2 moved by client B)
|
# Should have moved 3 animals (5 original - 2 moved by client B)
|
||||||
assert len(payload["resolved_ids"]) == 3
|
assert len(payload["resolved_ids"]) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoveRecentEvents:
|
||||||
|
"""Tests for recent events display on move page."""
|
||||||
|
|
||||||
|
def test_move_form_shows_recent_events_section(self, client):
|
||||||
|
"""Move form shows Recent Moves section."""
|
||||||
|
resp = client.get("/move")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Recent Moves" in resp.text
|
||||||
|
|
||||||
|
def test_move_event_appears_in_recent(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
seeded_db,
|
||||||
|
animal_service,
|
||||||
|
location_strip1_id,
|
||||||
|
location_strip2_id,
|
||||||
|
ducks_at_strip1,
|
||||||
|
):
|
||||||
|
"""Newly created move event appears in recent events list."""
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
filter_str = 'location:"Strip 1"'
|
||||||
|
filter_ast = parse_filter(filter_str)
|
||||||
|
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
|
||||||
|
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-move",
|
||||||
|
data={
|
||||||
|
"filter": filter_str,
|
||||||
|
"to_location_id": location_strip2_id,
|
||||||
|
"resolved_ids": resolution.animal_ids,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"from_location_id": location_strip1_id,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-nonce-recent-move-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Recent events should include the newly created event
|
||||||
|
assert "/events/" in resp.text
|
||||||
|
|
||||||
|
def test_move_event_links_to_detail(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
seeded_db,
|
||||||
|
animal_service,
|
||||||
|
location_strip1_id,
|
||||||
|
location_strip2_id,
|
||||||
|
ducks_at_strip1,
|
||||||
|
):
|
||||||
|
"""Move events in recent list link to event detail page."""
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
filter_str = 'location:"Strip 1"'
|
||||||
|
filter_ast = parse_filter(filter_str)
|
||||||
|
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
|
||||||
|
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-move",
|
||||||
|
data={
|
||||||
|
"filter": filter_str,
|
||||||
|
"to_location_id": location_strip2_id,
|
||||||
|
"resolved_ids": resolution.animal_ids,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"from_location_id": location_strip1_id,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-nonce-recent-move-2",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Get the event ID from DB
|
||||||
|
event_row = seeded_db.execute(
|
||||||
|
"SELECT id FROM events WHERE type = 'AnimalMoved' ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
event_id = event_row[0]
|
||||||
|
|
||||||
|
# The response should contain a link to the event detail
|
||||||
|
assert f"/events/{event_id}" in resp.text
|
||||||
|
|
||||||
|
def test_days_since_last_move_shows_today(
|
||||||
|
self,
|
||||||
|
client,
|
||||||
|
seeded_db,
|
||||||
|
animal_service,
|
||||||
|
location_strip1_id,
|
||||||
|
location_strip2_id,
|
||||||
|
ducks_at_strip1,
|
||||||
|
):
|
||||||
|
"""After a move today, shows 'Last move: today'."""
|
||||||
|
ts_utc = int(time.time() * 1000)
|
||||||
|
filter_str = 'location:"Strip 1"'
|
||||||
|
filter_ast = parse_filter(filter_str)
|
||||||
|
resolution = resolve_filter(seeded_db, filter_ast, ts_utc)
|
||||||
|
roster_hash = compute_roster_hash(resolution.animal_ids, location_strip1_id)
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/actions/animal-move",
|
||||||
|
data={
|
||||||
|
"filter": filter_str,
|
||||||
|
"to_location_id": location_strip2_id,
|
||||||
|
"resolved_ids": resolution.animal_ids,
|
||||||
|
"roster_hash": roster_hash,
|
||||||
|
"from_location_id": location_strip1_id,
|
||||||
|
"ts_utc": str(ts_utc),
|
||||||
|
"nonce": "test-nonce-recent-move-3",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Stats should show "Last move: today"
|
||||||
|
assert "Last move: today" in resp.text
|
||||||
|
|||||||
Reference in New Issue
Block a user