Fix tombstone bug in stats and add cost statistics to forms
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:
2026-01-09 06:19:30 +00:00
parent e42eede010
commit d91ee362fa
7 changed files with 299 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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