Fix tombstone bug in stats and add cost statistics to forms
All checks were successful
Deploy / deploy (push) Successful in 1m38s
All checks were successful
Deploy / deploy (push) Successful in 1m38s
Bug fix: Stats queries (eggs/day, feed/bird/day, etc.) were not excluding tombstoned (deleted) events. Updated EventStore.list_events() to exclude tombstoned events by default via LEFT JOIN, and updated direct SQL queries in stats.py with the same tombstone exclusion. New stats added: - Harvest form: cost/egg (global, 30-day avg) - Sell form: avg price/egg (30-day) - Give feed form: cost/bird/day (global, 30-day avg) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -144,6 +144,7 @@ class EventStore:
|
|||||||
until_utc: int | None = None,
|
until_utc: int | None = None,
|
||||||
actor: str | None = None,
|
actor: str | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
|
include_tombstoned: bool = False,
|
||||||
) -> list[Event]:
|
) -> list[Event]:
|
||||||
"""List events with optional filters.
|
"""List events with optional filters.
|
||||||
|
|
||||||
@@ -153,34 +154,44 @@ class EventStore:
|
|||||||
until_utc: Include events with ts_utc <= until_utc.
|
until_utc: Include events with ts_utc <= until_utc.
|
||||||
actor: Filter by actor.
|
actor: Filter by actor.
|
||||||
limit: Maximum number of events to return.
|
limit: Maximum number of events to return.
|
||||||
|
include_tombstoned: If True, include tombstoned (deleted) events.
|
||||||
|
Defaults to False, excluding tombstoned events.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of events ordered by ts_utc ASC.
|
List of events ordered by ts_utc ASC.
|
||||||
"""
|
"""
|
||||||
query = "SELECT id, type, ts_utc, actor, entity_refs, payload, version FROM events"
|
query = """
|
||||||
|
SELECT e.id, e.type, e.ts_utc, e.actor, e.entity_refs, e.payload, e.version
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
"""
|
||||||
conditions = []
|
conditions = []
|
||||||
params: list = []
|
params: list = []
|
||||||
|
|
||||||
|
# Exclude tombstoned events unless explicitly included
|
||||||
|
if not include_tombstoned:
|
||||||
|
conditions.append("t.target_event_id IS NULL")
|
||||||
|
|
||||||
if event_type is not None:
|
if event_type is not None:
|
||||||
conditions.append("type = ?")
|
conditions.append("e.type = ?")
|
||||||
params.append(event_type)
|
params.append(event_type)
|
||||||
|
|
||||||
if since_utc is not None:
|
if since_utc is not None:
|
||||||
conditions.append("ts_utc >= ?")
|
conditions.append("e.ts_utc >= ?")
|
||||||
params.append(since_utc)
|
params.append(since_utc)
|
||||||
|
|
||||||
if until_utc is not None:
|
if until_utc is not None:
|
||||||
conditions.append("ts_utc <= ?")
|
conditions.append("e.ts_utc <= ?")
|
||||||
params.append(until_utc)
|
params.append(until_utc)
|
||||||
|
|
||||||
if actor is not None:
|
if actor is not None:
|
||||||
conditions.append("actor = ?")
|
conditions.append("e.actor = ?")
|
||||||
params.append(actor)
|
params.append(actor)
|
||||||
|
|
||||||
if conditions:
|
if conditions:
|
||||||
query += " WHERE " + " AND ".join(conditions)
|
query += " WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
query += " ORDER BY ts_utc ASC"
|
query += " ORDER BY e.ts_utc ASC"
|
||||||
query += f" LIMIT {limit}"
|
query += f" LIMIT {limit}"
|
||||||
|
|
||||||
rows = self.db.execute(query, tuple(params)).fetchall()
|
rows = self.db.execute(query, tuple(params)).fetchall()
|
||||||
|
|||||||
@@ -149,16 +149,19 @@ def _count_eggs_in_window(
|
|||||||
|
|
||||||
Returns (eggs_count, species) where species is extracted from product_code.
|
Returns (eggs_count, species) where species is extracted from product_code.
|
||||||
Window is inclusive on both ends: [window_start, window_end].
|
Window is inclusive on both ends: [window_start, window_end].
|
||||||
|
Excludes tombstoned (deleted) events.
|
||||||
"""
|
"""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT json_extract(entity_refs, '$.product_code') as product_code,
|
SELECT json_extract(e.entity_refs, '$.product_code') as product_code,
|
||||||
json_extract(entity_refs, '$.quantity') as quantity
|
json_extract(e.entity_refs, '$.quantity') as quantity
|
||||||
FROM events
|
FROM events e
|
||||||
WHERE type = 'ProductCollected'
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
AND json_extract(entity_refs, '$.location_id') = :location_id
|
WHERE e.type = 'ProductCollected'
|
||||||
AND ts_utc >= :window_start
|
AND json_extract(e.entity_refs, '$.location_id') = :location_id
|
||||||
AND ts_utc <= :window_end
|
AND e.ts_utc >= :window_start
|
||||||
|
AND e.ts_utc <= :window_end
|
||||||
|
AND t.target_event_id IS NULL
|
||||||
""",
|
""",
|
||||||
{"location_id": location_id, "window_start": window_start, "window_end": window_end},
|
{"location_id": location_id, "window_start": window_start, "window_end": window_end},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -182,16 +185,19 @@ def _get_feed_events_in_window(
|
|||||||
"""Get all FeedGiven events at location in window.
|
"""Get all FeedGiven events at location in window.
|
||||||
|
|
||||||
Window is inclusive on both ends: [window_start, window_end].
|
Window is inclusive on both ends: [window_start, window_end].
|
||||||
|
Excludes tombstoned (deleted) events.
|
||||||
"""
|
"""
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT ts_utc, entity_refs
|
SELECT e.ts_utc, e.entity_refs
|
||||||
FROM events
|
FROM events e
|
||||||
WHERE type = 'FeedGiven'
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
AND json_extract(entity_refs, '$.location_id') = :location_id
|
WHERE e.type = 'FeedGiven'
|
||||||
AND ts_utc >= :window_start
|
AND json_extract(e.entity_refs, '$.location_id') = :location_id
|
||||||
AND ts_utc <= :window_end
|
AND e.ts_utc >= :window_start
|
||||||
ORDER BY ts_utc
|
AND e.ts_utc <= :window_end
|
||||||
|
AND t.target_event_id IS NULL
|
||||||
|
ORDER BY e.ts_utc
|
||||||
""",
|
""",
|
||||||
{"location_id": location_id, "window_start": window_start, "window_end": window_end},
|
{"location_id": location_id, "window_start": window_start, "window_end": window_end},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
@@ -213,15 +219,18 @@ def _get_feed_price_at_time(db: Any, feed_type_code: str, ts_utc: int) -> int:
|
|||||||
"""Get the feed price per kg in cents at a given time.
|
"""Get the feed price per kg in cents at a given time.
|
||||||
|
|
||||||
Returns the price from the most recent FeedPurchased event <= ts_utc.
|
Returns the price from the most recent FeedPurchased event <= ts_utc.
|
||||||
|
Excludes tombstoned (deleted) events.
|
||||||
"""
|
"""
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"""
|
"""
|
||||||
SELECT json_extract(entity_refs, '$.price_per_kg_cents') as price
|
SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price
|
||||||
FROM events
|
FROM events e
|
||||||
WHERE type = 'FeedPurchased'
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
AND json_extract(entity_refs, '$.feed_type_code') = :feed_type_code
|
WHERE e.type = 'FeedPurchased'
|
||||||
AND ts_utc <= :ts_utc
|
AND json_extract(e.entity_refs, '$.feed_type_code') = :feed_type_code
|
||||||
ORDER BY ts_utc DESC
|
AND e.ts_utc <= :ts_utc
|
||||||
|
AND t.target_event_id IS NULL
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
{"feed_type_code": feed_type_code, "ts_utc": ts_utc},
|
{"feed_type_code": feed_type_code, "ts_utc": ts_utc},
|
||||||
|
|||||||
@@ -169,6 +169,75 @@ def _get_eggs_per_day(db: Any, now_ms: int) -> float | None:
|
|||||||
return total_eggs / 30.0
|
return total_eggs / 30.0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_global_cost_per_egg(db: Any, now_ms: int) -> float | None:
|
||||||
|
"""Calculate global cost per egg over 30-day window.
|
||||||
|
|
||||||
|
Aggregates feed costs and egg counts across all locations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Cost per egg in EUR, or None if no eggs collected.
|
||||||
|
"""
|
||||||
|
from animaltrack.events import FEED_GIVEN
|
||||||
|
|
||||||
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
|
event_store = EventStore(db)
|
||||||
|
|
||||||
|
# Count eggs across all locations
|
||||||
|
egg_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 egg_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
|
||||||
|
|
||||||
|
# Sum feed costs across all locations
|
||||||
|
feed_events = event_store.list_events(
|
||||||
|
event_type=FEED_GIVEN,
|
||||||
|
since_utc=window_start,
|
||||||
|
until_utc=now_ms,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_cost_cents = 0.0
|
||||||
|
for event in feed_events:
|
||||||
|
amount_kg = event.entity_refs.get("amount_kg", 0)
|
||||||
|
feed_type_code = event.entity_refs.get("feed_type_code", "")
|
||||||
|
|
||||||
|
# Look up price at the time of feeding
|
||||||
|
price_row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
WHERE e.type = 'FeedPurchased'
|
||||||
|
AND json_extract(e.entity_refs, '$.feed_type_code') = ?
|
||||||
|
AND e.ts_utc <= ?
|
||||||
|
AND t.target_event_id IS NULL
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(feed_type_code, event.ts_utc),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
price_per_kg_cents = price_row[0] if price_row else 0
|
||||||
|
total_cost_cents += amount_kg * price_per_kg_cents
|
||||||
|
|
||||||
|
return (total_cost_cents / 100) / total_eggs
|
||||||
|
|
||||||
|
|
||||||
def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
|
def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
|
||||||
"""Calculate sales statistics over 30-day window.
|
"""Calculate sales statistics over 30-day window.
|
||||||
|
|
||||||
@@ -177,7 +246,8 @@ def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
|
|||||||
now_ms: Current timestamp in milliseconds.
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with 'total_qty' and 'total_cents', or None if no data.
|
Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents',
|
||||||
|
or None if no data.
|
||||||
"""
|
"""
|
||||||
window_start = now_ms - THIRTY_DAYS_MS
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
event_store = EventStore(db)
|
event_store = EventStore(db)
|
||||||
@@ -197,7 +267,13 @@ def _get_sales_stats(db: Any, now_ms: int) -> dict | None:
|
|||||||
total_qty += event.entity_refs.get("quantity", 0)
|
total_qty += event.entity_refs.get("quantity", 0)
|
||||||
total_cents += event.entity_refs.get("total_price_cents", 0)
|
total_cents += event.entity_refs.get("total_price_cents", 0)
|
||||||
|
|
||||||
return {"total_qty": total_qty, "total_cents": total_cents}
|
avg_price_per_egg_cents = total_cents / total_qty if total_qty > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_qty": total_qty,
|
||||||
|
"total_cents": total_cents,
|
||||||
|
"avg_price_per_egg_cents": avg_price_per_egg_cents,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _get_eggs_display_data(db: Any, locations: list) -> dict:
|
def _get_eggs_display_data(db: Any, locations: list) -> dict:
|
||||||
@@ -208,13 +284,15 @@ def _get_eggs_display_data(db: Any, locations: list) -> dict:
|
|||||||
locations: List of Location objects for name lookup.
|
locations: List of Location objects for name lookup.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with harvest_events, sell_events, eggs_per_day, sales_stats, location_names.
|
Dict with harvest_events, sell_events, eggs_per_day, cost_per_egg,
|
||||||
|
sales_stats, location_names.
|
||||||
"""
|
"""
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
return {
|
return {
|
||||||
"harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10),
|
"harvest_events": _get_recent_events(db, PRODUCT_COLLECTED, limit=10),
|
||||||
"sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10),
|
"sell_events": _get_recent_events(db, PRODUCT_SOLD, limit=10),
|
||||||
"eggs_per_day": _get_eggs_per_day(db, now_ms),
|
"eggs_per_day": _get_eggs_per_day(db, now_ms),
|
||||||
|
"cost_per_egg": _get_global_cost_per_egg(db, now_ms),
|
||||||
"sales_stats": _get_sales_stats(db, now_ms),
|
"sales_stats": _get_sales_stats(db, now_ms),
|
||||||
"location_names": {loc.id: loc.name for loc in locations},
|
"location_names": {loc.id: loc.name for loc in locations},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,83 @@ def _get_feed_per_bird_per_day(db: Any, now_ms: int) -> float | None:
|
|||||||
return total_g / bird_days
|
return total_g / bird_days
|
||||||
|
|
||||||
|
|
||||||
|
def _get_cost_per_bird_per_day(db: Any, now_ms: int) -> float | None:
|
||||||
|
"""Calculate feed cost per bird per day over 30-day window.
|
||||||
|
|
||||||
|
Uses global bird-days and feed costs across all locations.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connection.
|
||||||
|
now_ms: Current timestamp in milliseconds.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Feed cost in EUR per bird per day, or None if no data.
|
||||||
|
"""
|
||||||
|
window_start = now_ms - THIRTY_DAYS_MS
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# Get total feed cost 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not events:
|
||||||
|
return None
|
||||||
|
|
||||||
|
total_cost_cents = 0.0
|
||||||
|
for event in events:
|
||||||
|
amount_kg = event.entity_refs.get("amount_kg", 0)
|
||||||
|
feed_type_code = event.entity_refs.get("feed_type_code", "")
|
||||||
|
|
||||||
|
# Look up price at the time of feeding
|
||||||
|
price_row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT json_extract(e.entity_refs, '$.price_per_kg_cents') as price
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||||
|
WHERE e.type = 'FeedPurchased'
|
||||||
|
AND json_extract(e.entity_refs, '$.feed_type_code') = ?
|
||||||
|
AND e.ts_utc <= ?
|
||||||
|
AND t.target_event_id IS NULL
|
||||||
|
ORDER BY e.ts_utc DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(feed_type_code, event.ts_utc),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
price_per_kg_cents = price_row[0] if price_row else 0
|
||||||
|
total_cost_cents += amount_kg * price_per_kg_cents
|
||||||
|
|
||||||
|
# Convert to EUR and divide by bird-days
|
||||||
|
return (total_cost_cents / 100) / bird_days
|
||||||
|
|
||||||
|
|
||||||
def _get_purchase_stats(db: Any, now_ms: int) -> dict | None:
|
def _get_purchase_stats(db: Any, now_ms: int) -> dict | None:
|
||||||
"""Calculate purchase statistics over 30-day window.
|
"""Calculate purchase statistics over 30-day window.
|
||||||
|
|
||||||
@@ -221,6 +298,7 @@ def _get_feed_display_data(db: Any, locations: list, feed_types: list) -> dict:
|
|||||||
"give_events": _get_recent_events(db, FEED_GIVEN, limit=10),
|
"give_events": _get_recent_events(db, FEED_GIVEN, limit=10),
|
||||||
"purchase_events": _get_recent_events(db, FEED_PURCHASED, 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),
|
"feed_per_bird_per_day_g": _get_feed_per_bird_per_day(db, now_ms),
|
||||||
|
"cost_per_bird_per_day": _get_cost_per_bird_per_day(db, now_ms),
|
||||||
"purchase_stats": _get_purchase_stats(db, now_ms),
|
"purchase_stats": _get_purchase_stats(db, now_ms),
|
||||||
"location_names": {loc.id: loc.name for loc in locations},
|
"location_names": {loc.id: loc.name for loc in locations},
|
||||||
"feed_type_names": {ft.code: ft.name for ft in feed_types},
|
"feed_type_names": {ft.code: ft.name for ft in feed_types},
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ def eggs_page(
|
|||||||
harvest_events: list[tuple[Event, bool]] | None = None,
|
harvest_events: list[tuple[Event, bool]] | None = None,
|
||||||
sell_events: list[tuple[Event, bool]] | None = None,
|
sell_events: list[tuple[Event, bool]] | None = None,
|
||||||
eggs_per_day: float | None = None,
|
eggs_per_day: float | None = None,
|
||||||
|
cost_per_egg: float | None = None,
|
||||||
sales_stats: dict | None = None,
|
sales_stats: dict | None = None,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
):
|
):
|
||||||
@@ -52,7 +53,8 @@ def eggs_page(
|
|||||||
harvest_events: Recent ProductCollected events (most recent first).
|
harvest_events: Recent ProductCollected events (most recent first).
|
||||||
sell_events: Recent ProductSold events (most recent first).
|
sell_events: Recent ProductSold events (most recent first).
|
||||||
eggs_per_day: 30-day average eggs per day.
|
eggs_per_day: 30-day average eggs per day.
|
||||||
sales_stats: Dict with 'total_qty' and 'total_cents' for 30-day sales.
|
cost_per_egg: 30-day average cost per egg in EUR.
|
||||||
|
sales_stats: Dict with 'total_qty', 'total_cents', and 'avg_price_per_egg_cents'.
|
||||||
location_names: Dict mapping location_id to location name for display.
|
location_names: Dict mapping location_id to location name for display.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -81,6 +83,7 @@ def eggs_page(
|
|||||||
action=harvest_action,
|
action=harvest_action,
|
||||||
recent_events=harvest_events,
|
recent_events=harvest_events,
|
||||||
eggs_per_day=eggs_per_day,
|
eggs_per_day=eggs_per_day,
|
||||||
|
cost_per_egg=cost_per_egg,
|
||||||
location_names=location_names,
|
location_names=location_names,
|
||||||
),
|
),
|
||||||
cls="uk-active" if harvest_active else None,
|
cls="uk-active" if harvest_active else None,
|
||||||
@@ -108,6 +111,7 @@ def harvest_form(
|
|||||||
action: Callable[..., Any] | str = "/actions/product-collected",
|
action: Callable[..., Any] | str = "/actions/product-collected",
|
||||||
recent_events: list[tuple[Event, bool]] | None = None,
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
eggs_per_day: float | None = None,
|
eggs_per_day: float | None = None,
|
||||||
|
cost_per_egg: float | None = None,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
) -> Div:
|
) -> Div:
|
||||||
"""Create the Harvest form for egg collection.
|
"""Create the Harvest form for egg collection.
|
||||||
@@ -119,6 +123,7 @@ def harvest_form(
|
|||||||
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.
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
eggs_per_day: 30-day average eggs per day.
|
eggs_per_day: 30-day average eggs per day.
|
||||||
|
cost_per_egg: 30-day average cost per egg in EUR.
|
||||||
location_names: Dict mapping location_id to location name for display.
|
location_names: Dict mapping location_id to location name for display.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@@ -160,10 +165,13 @@ def harvest_form(
|
|||||||
loc_name = location_names.get(loc_id, "Unknown")
|
loc_name = location_names.get(loc_id, "Unknown")
|
||||||
return f"{quantity} eggs from {loc_name}", event.id
|
return f"{quantity} eggs from {loc_name}", event.id
|
||||||
|
|
||||||
# Build stats text
|
# Build stats text - combine eggs/day and cost/egg
|
||||||
stat_text = None
|
stat_parts = []
|
||||||
if eggs_per_day is not None:
|
if eggs_per_day is not None:
|
||||||
stat_text = f"{eggs_per_day:.1f} eggs/day (30-day avg)"
|
stat_parts.append(f"{eggs_per_day:.1f} eggs/day")
|
||||||
|
if cost_per_egg is not None:
|
||||||
|
stat_parts.append(f"€{cost_per_egg:.3f}/egg cost")
|
||||||
|
stat_text = " | ".join(stat_parts) + " (30-day avg)" if stat_parts else None
|
||||||
|
|
||||||
form = Form(
|
form = Form(
|
||||||
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
||||||
@@ -275,13 +283,18 @@ def sell_form(
|
|||||||
total_eur = total_cents / 100
|
total_eur = total_cents / 100
|
||||||
return f"{quantity} {product_code} for €{total_eur:.2f}", event.id
|
return f"{quantity} {product_code} for €{total_eur:.2f}", event.id
|
||||||
|
|
||||||
# Build stats text
|
# Build stats text - combine total sold, revenue, and avg price
|
||||||
stat_text = None
|
stat_parts = []
|
||||||
total_qty = sales_stats.get("total_qty")
|
total_qty = sales_stats.get("total_qty")
|
||||||
total_cents = sales_stats.get("total_cents")
|
total_cents = sales_stats.get("total_cents")
|
||||||
|
avg_price_cents = sales_stats.get("avg_price_per_egg_cents")
|
||||||
if total_qty is not None and total_cents is not None:
|
if total_qty is not None and total_cents is not None:
|
||||||
total_eur = total_cents / 100
|
total_eur = total_cents / 100
|
||||||
stat_text = f"{total_qty} sold for €{total_eur:.2f} (30-day total)"
|
stat_parts.append(f"{total_qty} sold for €{total_eur:.2f}")
|
||||||
|
if avg_price_cents is not None and avg_price_cents > 0:
|
||||||
|
avg_price_eur = avg_price_cents / 100
|
||||||
|
stat_parts.append(f"€{avg_price_eur:.2f}/egg avg")
|
||||||
|
stat_text = " | ".join(stat_parts) + " (30-day)" if stat_parts else None
|
||||||
|
|
||||||
form = Form(
|
form = Form(
|
||||||
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
H2("Sell Products", cls="text-xl font-bold mb-4"),
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def feed_page(
|
|||||||
give_events: list[tuple[Event, bool]] | None = None,
|
give_events: list[tuple[Event, bool]] | None = None,
|
||||||
purchase_events: list[tuple[Event, bool]] | None = None,
|
purchase_events: list[tuple[Event, bool]] | None = None,
|
||||||
feed_per_bird_per_day_g: float | None = None,
|
feed_per_bird_per_day_g: float | None = None,
|
||||||
|
cost_per_bird_per_day: float | None = None,
|
||||||
purchase_stats: dict | None = None,
|
purchase_stats: dict | None = None,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
feed_type_names: dict[str, str] | None = None,
|
feed_type_names: dict[str, str] | None = None,
|
||||||
@@ -57,6 +58,7 @@ def feed_page(
|
|||||||
give_events: Recent FeedGiven events (most recent first).
|
give_events: Recent FeedGiven events (most recent first).
|
||||||
purchase_events: Recent FeedPurchased 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.
|
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
|
||||||
|
cost_per_bird_per_day: Average feed cost per bird per day in EUR.
|
||||||
purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'.
|
purchase_stats: Dict with 'total_kg' and 'avg_price_per_kg_cents'.
|
||||||
location_names: Dict mapping location_id to location name.
|
location_names: Dict mapping location_id to location name.
|
||||||
feed_type_names: Dict mapping feed_type_code to feed type name.
|
feed_type_names: Dict mapping feed_type_code to feed type name.
|
||||||
@@ -93,6 +95,7 @@ def feed_page(
|
|||||||
action=give_action,
|
action=give_action,
|
||||||
recent_events=give_events,
|
recent_events=give_events,
|
||||||
feed_per_bird_per_day_g=feed_per_bird_per_day_g,
|
feed_per_bird_per_day_g=feed_per_bird_per_day_g,
|
||||||
|
cost_per_bird_per_day=cost_per_bird_per_day,
|
||||||
location_names=location_names,
|
location_names=location_names,
|
||||||
feed_type_names=feed_type_names,
|
feed_type_names=feed_type_names,
|
||||||
),
|
),
|
||||||
@@ -125,6 +128,7 @@ def give_feed_form(
|
|||||||
action: Callable[..., Any] | str = "/actions/feed-given",
|
action: Callable[..., Any] | str = "/actions/feed-given",
|
||||||
recent_events: list[tuple[Event, bool]] | None = None,
|
recent_events: list[tuple[Event, bool]] | None = None,
|
||||||
feed_per_bird_per_day_g: float | None = None,
|
feed_per_bird_per_day_g: float | None = None,
|
||||||
|
cost_per_bird_per_day: float | None = None,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
feed_type_names: dict[str, str] | None = None,
|
feed_type_names: dict[str, str] | None = None,
|
||||||
) -> Div:
|
) -> Div:
|
||||||
@@ -141,6 +145,7 @@ def give_feed_form(
|
|||||||
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.
|
recent_events: Recent (Event, is_deleted) tuples, most recent first.
|
||||||
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
|
feed_per_bird_per_day_g: Average feed consumption in g/bird/day.
|
||||||
|
cost_per_bird_per_day: Average feed cost per bird per day in EUR.
|
||||||
location_names: Dict mapping location_id to location name.
|
location_names: Dict mapping location_id to location name.
|
||||||
feed_type_names: Dict mapping feed_type_code to feed type name.
|
feed_type_names: Dict mapping feed_type_code to feed type name.
|
||||||
|
|
||||||
@@ -207,10 +212,13 @@ def give_feed_form(
|
|||||||
feed_name = feed_type_names.get(feed_code, feed_code)
|
feed_name = feed_type_names.get(feed_code, feed_code)
|
||||||
return f"{amount_kg}kg {feed_name} to {loc_name}", event.id
|
return f"{amount_kg}kg {feed_name} to {loc_name}", event.id
|
||||||
|
|
||||||
# Build stats text
|
# Build stats text - combine g/bird/day and cost/bird/day
|
||||||
stat_text = None
|
stat_parts = []
|
||||||
if feed_per_bird_per_day_g is not 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)"
|
stat_parts.append(f"{feed_per_bird_per_day_g:.1f}g/bird/day")
|
||||||
|
if cost_per_bird_per_day is not None:
|
||||||
|
stat_parts.append(f"€{cost_per_bird_per_day:.3f}/bird/day cost")
|
||||||
|
stat_text = " | ".join(stat_parts) + " (30-day avg)" if stat_parts else None
|
||||||
|
|
||||||
form = Form(
|
form = Form(
|
||||||
H2("Give Feed", cls="text-xl font-bold mb-4"),
|
H2("Give Feed", cls="text-xl font-bold mb-4"),
|
||||||
|
|||||||
@@ -359,3 +359,66 @@ class TestTombstoneChecking:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert event_store.is_tombstoned(event.id) is True
|
assert event_store.is_tombstoned(event.id) is True
|
||||||
|
|
||||||
|
def test_list_events_excludes_tombstoned_by_default(self, migrated_db, event_store, now_utc):
|
||||||
|
"""list_events excludes tombstoned events by default."""
|
||||||
|
event1 = event_store.append_event(
|
||||||
|
event_type=PRODUCT_COLLECTED,
|
||||||
|
ts_utc=now_utc,
|
||||||
|
actor="ppetru",
|
||||||
|
entity_refs={},
|
||||||
|
payload={"order": 1},
|
||||||
|
)
|
||||||
|
event2 = event_store.append_event(
|
||||||
|
event_type=PRODUCT_COLLECTED,
|
||||||
|
ts_utc=now_utc + 1000,
|
||||||
|
actor="ppetru",
|
||||||
|
entity_refs={},
|
||||||
|
payload={"order": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tombstone event1
|
||||||
|
tombstone_id = generate_id()
|
||||||
|
migrated_db.execute(
|
||||||
|
"""INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(tombstone_id, now_utc + 2000, "admin", event1.id, "Test deletion"),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = event_store.list_events()
|
||||||
|
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].id == event2.id
|
||||||
|
|
||||||
|
def test_list_events_includes_tombstoned_when_requested(
|
||||||
|
self, migrated_db, event_store, now_utc
|
||||||
|
):
|
||||||
|
"""list_events includes tombstoned events when include_tombstoned=True."""
|
||||||
|
event1 = event_store.append_event(
|
||||||
|
event_type=PRODUCT_COLLECTED,
|
||||||
|
ts_utc=now_utc,
|
||||||
|
actor="ppetru",
|
||||||
|
entity_refs={},
|
||||||
|
payload={"order": 1},
|
||||||
|
)
|
||||||
|
event2 = event_store.append_event(
|
||||||
|
event_type=PRODUCT_COLLECTED,
|
||||||
|
ts_utc=now_utc + 1000,
|
||||||
|
actor="ppetru",
|
||||||
|
entity_refs={},
|
||||||
|
payload={"order": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tombstone event1
|
||||||
|
tombstone_id = generate_id()
|
||||||
|
migrated_db.execute(
|
||||||
|
"""INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason)
|
||||||
|
VALUES (?, ?, ?, ?, ?)""",
|
||||||
|
(tombstone_id, now_utc + 2000, "admin", event1.id, "Test deletion"),
|
||||||
|
)
|
||||||
|
|
||||||
|
events = event_store.list_events(include_tombstoned=True)
|
||||||
|
|
||||||
|
assert len(events) == 2
|
||||||
|
assert events[0].id == event1.id
|
||||||
|
assert events[1].id == event2.id
|
||||||
|
|||||||
Reference in New Issue
Block a user