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,
|
||||
actor: str | None = None,
|
||||
limit: int = 100,
|
||||
include_tombstoned: bool = False,
|
||||
) -> list[Event]:
|
||||
"""List events with optional filters.
|
||||
|
||||
@@ -153,34 +154,44 @@ class EventStore:
|
||||
until_utc: Include events with ts_utc <= until_utc.
|
||||
actor: Filter by actor.
|
||||
limit: Maximum number of events to return.
|
||||
include_tombstoned: If True, include tombstoned (deleted) events.
|
||||
Defaults to False, excluding tombstoned events.
|
||||
|
||||
Returns:
|
||||
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 = []
|
||||
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:
|
||||
conditions.append("type = ?")
|
||||
conditions.append("e.type = ?")
|
||||
params.append(event_type)
|
||||
|
||||
if since_utc is not None:
|
||||
conditions.append("ts_utc >= ?")
|
||||
conditions.append("e.ts_utc >= ?")
|
||||
params.append(since_utc)
|
||||
|
||||
if until_utc is not None:
|
||||
conditions.append("ts_utc <= ?")
|
||||
conditions.append("e.ts_utc <= ?")
|
||||
params.append(until_utc)
|
||||
|
||||
if actor is not None:
|
||||
conditions.append("actor = ?")
|
||||
conditions.append("e.actor = ?")
|
||||
params.append(actor)
|
||||
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
|
||||
query += " ORDER BY ts_utc ASC"
|
||||
query += " ORDER BY e.ts_utc ASC"
|
||||
query += f" LIMIT {limit}"
|
||||
|
||||
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.
|
||||
Window is inclusive on both ends: [window_start, window_end].
|
||||
Excludes tombstoned (deleted) events.
|
||||
"""
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT json_extract(entity_refs, '$.product_code') as product_code,
|
||||
json_extract(entity_refs, '$.quantity') as quantity
|
||||
FROM events
|
||||
WHERE type = 'ProductCollected'
|
||||
AND json_extract(entity_refs, '$.location_id') = :location_id
|
||||
AND ts_utc >= :window_start
|
||||
AND ts_utc <= :window_end
|
||||
SELECT json_extract(e.entity_refs, '$.product_code') as product_code,
|
||||
json_extract(e.entity_refs, '$.quantity') as quantity
|
||||
FROM events e
|
||||
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||
WHERE e.type = 'ProductCollected'
|
||||
AND json_extract(e.entity_refs, '$.location_id') = :location_id
|
||||
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},
|
||||
).fetchall()
|
||||
@@ -182,16 +185,19 @@ def _get_feed_events_in_window(
|
||||
"""Get all FeedGiven events at location in window.
|
||||
|
||||
Window is inclusive on both ends: [window_start, window_end].
|
||||
Excludes tombstoned (deleted) events.
|
||||
"""
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT ts_utc, entity_refs
|
||||
FROM events
|
||||
WHERE type = 'FeedGiven'
|
||||
AND json_extract(entity_refs, '$.location_id') = :location_id
|
||||
AND ts_utc >= :window_start
|
||||
AND ts_utc <= :window_end
|
||||
ORDER BY ts_utc
|
||||
SELECT e.ts_utc, e.entity_refs
|
||||
FROM events e
|
||||
LEFT JOIN event_tombstones t ON e.id = t.target_event_id
|
||||
WHERE e.type = 'FeedGiven'
|
||||
AND json_extract(e.entity_refs, '$.location_id') = :location_id
|
||||
AND e.ts_utc >= :window_start
|
||||
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},
|
||||
).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.
|
||||
|
||||
Returns the price from the most recent FeedPurchased event <= ts_utc.
|
||||
Excludes tombstoned (deleted) events.
|
||||
"""
|
||||
row = db.execute(
|
||||
"""
|
||||
SELECT json_extract(entity_refs, '$.price_per_kg_cents') as price
|
||||
FROM events
|
||||
WHERE type = 'FeedPurchased'
|
||||
AND json_extract(entity_refs, '$.feed_type_code') = :feed_type_code
|
||||
AND ts_utc <= :ts_utc
|
||||
ORDER BY ts_utc DESC
|
||||
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') = :feed_type_code
|
||||
AND e.ts_utc <= :ts_utc
|
||||
AND t.target_event_id IS NULL
|
||||
ORDER BY e.ts_utc DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
{"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
|
||||
|
||||
|
||||
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:
|
||||
"""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.
|
||||
|
||||
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
|
||||
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_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:
|
||||
@@ -208,13 +284,15 @@ def _get_eggs_display_data(db: Any, locations: list) -> dict:
|
||||
locations: List of Location objects for name lookup.
|
||||
|
||||
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)
|
||||
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),
|
||||
"cost_per_egg": _get_global_cost_per_egg(db, now_ms),
|
||||
"sales_stats": _get_sales_stats(db, now_ms),
|
||||
"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
|
||||
|
||||
|
||||
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:
|
||||
"""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),
|
||||
"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),
|
||||
"cost_per_bird_per_day": _get_cost_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},
|
||||
|
||||
@@ -34,6 +34,7 @@ def eggs_page(
|
||||
harvest_events: list[tuple[Event, bool]] | None = None,
|
||||
sell_events: list[tuple[Event, bool]] | None = None,
|
||||
eggs_per_day: float | None = None,
|
||||
cost_per_egg: float | None = None,
|
||||
sales_stats: dict | None = None,
|
||||
location_names: dict[str, str] | None = None,
|
||||
):
|
||||
@@ -52,7 +53,8 @@ def eggs_page(
|
||||
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.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
@@ -81,6 +83,7 @@ def eggs_page(
|
||||
action=harvest_action,
|
||||
recent_events=harvest_events,
|
||||
eggs_per_day=eggs_per_day,
|
||||
cost_per_egg=cost_per_egg,
|
||||
location_names=location_names,
|
||||
),
|
||||
cls="uk-active" if harvest_active else None,
|
||||
@@ -108,6 +111,7 @@ def harvest_form(
|
||||
action: Callable[..., Any] | str = "/actions/product-collected",
|
||||
recent_events: list[tuple[Event, bool]] | None = None,
|
||||
eggs_per_day: float | None = None,
|
||||
cost_per_egg: float | None = None,
|
||||
location_names: dict[str, str] | None = None,
|
||||
) -> Div:
|
||||
"""Create the Harvest form for egg collection.
|
||||
@@ -119,6 +123,7 @@ def harvest_form(
|
||||
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.
|
||||
cost_per_egg: 30-day average cost per egg in EUR.
|
||||
location_names: Dict mapping location_id to location name for display.
|
||||
|
||||
Returns:
|
||||
@@ -160,10 +165,13 @@ def harvest_form(
|
||||
loc_name = location_names.get(loc_id, "Unknown")
|
||||
return f"{quantity} eggs from {loc_name}", event.id
|
||||
|
||||
# Build stats text
|
||||
stat_text = None
|
||||
# Build stats text - combine eggs/day and cost/egg
|
||||
stat_parts = []
|
||||
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(
|
||||
H2("Harvest Eggs", cls="text-xl font-bold mb-4"),
|
||||
@@ -275,13 +283,18 @@ def sell_form(
|
||||
total_eur = total_cents / 100
|
||||
return f"{quantity} {product_code} for €{total_eur:.2f}", event.id
|
||||
|
||||
# Build stats text
|
||||
stat_text = None
|
||||
# Build stats text - combine total sold, revenue, and avg price
|
||||
stat_parts = []
|
||||
total_qty = sales_stats.get("total_qty")
|
||||
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:
|
||||
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(
|
||||
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,
|
||||
purchase_events: list[tuple[Event, bool]] | None = None,
|
||||
feed_per_bird_per_day_g: float | None = None,
|
||||
cost_per_bird_per_day: float | None = None,
|
||||
purchase_stats: dict | None = None,
|
||||
location_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).
|
||||
purchase_events: Recent FeedPurchased events (most recent first).
|
||||
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'.
|
||||
location_names: Dict mapping location_id to location name.
|
||||
feed_type_names: Dict mapping feed_type_code to feed type name.
|
||||
@@ -93,6 +95,7 @@ def feed_page(
|
||||
action=give_action,
|
||||
recent_events=give_events,
|
||||
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,
|
||||
feed_type_names=feed_type_names,
|
||||
),
|
||||
@@ -125,6 +128,7 @@ def give_feed_form(
|
||||
action: Callable[..., Any] | str = "/actions/feed-given",
|
||||
recent_events: list[tuple[Event, bool]] | 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,
|
||||
feed_type_names: dict[str, str] | None = None,
|
||||
) -> Div:
|
||||
@@ -141,6 +145,7 @@ def give_feed_form(
|
||||
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.
|
||||
cost_per_bird_per_day: Average feed cost per bird per day in EUR.
|
||||
location_names: Dict mapping location_id to location 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)
|
||||
return f"{amount_kg}kg {feed_name} to {loc_name}", event.id
|
||||
|
||||
# Build stats text
|
||||
stat_text = None
|
||||
# Build stats text - combine g/bird/day and cost/bird/day
|
||||
stat_parts = []
|
||||
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(
|
||||
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
|
||||
|
||||
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