feat: implement Event Log Projection & View (Step 8.2)

- Add migration 0008 for event_log_by_location table with cap trigger
- Create EventLogProjection for location-scoped event summaries
- Add GET /event-log route with location_id filtering
- Create event log templates with timeline styling
- Register EventLogProjection in eggs, feed, and move routes
- Cap events at 500 per location (trigger removes oldest)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-30 15:15:06 +00:00
parent 8e155080e4
commit bce4d099c9
14 changed files with 1355 additions and 10 deletions

View File

@@ -4,6 +4,7 @@
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.base import Projection, ProjectionRegistry
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.event_log import EventLogProjection
from animaltrack.projections.exceptions import ProjectionError
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.projections.products import ProductsProjection
@@ -11,6 +12,7 @@ from animaltrack.projections.products import ProductsProjection
__all__ = [
"AnimalRegistryProjection",
"EventAnimalsProjection",
"EventLogProjection",
"IntervalProjection",
"Projection",
"ProjectionError",

View File

@@ -0,0 +1,140 @@
# ABOUTME: Projection for creating event log entries per location.
# ABOUTME: Populates event_log_by_location table with event summaries.
import json
from typing import Any
from animaltrack.events.types import (
ANIMAL_COHORT_CREATED,
ANIMAL_MOVED,
ANIMAL_OUTCOME,
ANIMAL_TAG_ENDED,
ANIMAL_TAGGED,
FEED_GIVEN,
HATCH_RECORDED,
PRODUCT_COLLECTED,
)
from animaltrack.models.events import Event
from animaltrack.projections.base import Projection
# Event types that have a location_id directly in payload
LOCATION_EVENTS = frozenset(
{
ANIMAL_COHORT_CREATED,
ANIMAL_MOVED,
ANIMAL_OUTCOME,
ANIMAL_TAGGED,
ANIMAL_TAG_ENDED,
FEED_GIVEN,
HATCH_RECORDED,
PRODUCT_COLLECTED,
}
)
class EventLogProjection(Projection):
"""Projects events into event_log_by_location table.
Creates a summary entry for each location-scoped event.
Events without a location (FeedPurchased, ProductSold) are not logged.
"""
def __init__(self, db: Any) -> None:
"""Initialize the projection with a database connection."""
super().__init__(db)
def get_event_types(self) -> list[str]:
"""Return the event types this projection handles."""
return list(LOCATION_EVENTS)
def apply(self, event: Event) -> None:
"""Apply event by creating an entry in event_log_by_location."""
location_id = self._extract_location_id(event)
if location_id is None:
return
summary = self._build_summary(event)
self.db.execute(
"""
INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary)
VALUES (?, ?, ?, ?, ?, ?)
""",
(event.id, location_id, event.ts_utc, event.type, event.actor, json.dumps(summary)),
)
def revert(self, event: Event) -> None:
"""Revert event by removing entry from event_log_by_location."""
self.db.execute(
"DELETE FROM event_log_by_location WHERE event_id = ?",
(event.id,),
)
def _extract_location_id(self, event: Event) -> str | None:
"""Extract location_id from event based on event type."""
payload = event.payload
if event.type == ANIMAL_MOVED:
return payload.get("to_location_id")
# Most events have location_id directly in payload
if "location_id" in payload:
return payload["location_id"]
return None
def _build_summary(self, event: Event) -> dict[str, Any]:
"""Build summary JSON based on event type."""
payload = event.payload
if event.type == PRODUCT_COLLECTED:
return {
"product_code": payload.get("product_code"),
"quantity": payload.get("quantity"),
}
if event.type == ANIMAL_COHORT_CREATED:
return {
"species": payload.get("species"),
"count": payload.get("count"),
"origin": payload.get("origin"),
}
if event.type == FEED_GIVEN:
return {
"feed_type_code": payload.get("feed_type_code"),
"amount_kg": payload.get("amount_kg"),
}
if event.type == ANIMAL_MOVED:
animal_ids = payload.get("resolved_ids", [])
return {
"animal_count": len(animal_ids),
}
if event.type == HATCH_RECORDED:
return {
"species": payload.get("species"),
"hatched_live": payload.get("hatched_live"),
}
if event.type == ANIMAL_TAGGED:
return {
"tag": payload.get("tag"),
"animal_count": len(payload.get("resolved_ids", [])),
}
if event.type == ANIMAL_TAG_ENDED:
return {
"tag": payload.get("tag"),
"animal_count": len(payload.get("resolved_ids", [])),
}
if event.type == ANIMAL_OUTCOME:
return {
"outcome": payload.get("outcome"),
"animal_count": len(payload.get("resolved_ids", [])),
}
# Fallback - include basic info
return {"event_type": event.type}

View File

@@ -19,6 +19,7 @@ from animaltrack.web.middleware import (
)
from animaltrack.web.routes import (
register_egg_routes,
register_events_routes,
register_feed_routes,
register_health_routes,
register_move_routes,
@@ -132,6 +133,7 @@ def create_app(
# Register routes
register_health_routes(rt, app)
register_egg_routes(rt, app)
register_events_routes(rt, app)
register_feed_routes(rt, app)
register_move_routes(rt, app)
register_registry_routes(rt, app)

View File

@@ -2,6 +2,7 @@
# ABOUTME: Contains modular route handlers for different features.
from animaltrack.web.routes.eggs import register_egg_routes
from animaltrack.web.routes.events import register_events_routes
from animaltrack.web.routes.feed import register_feed_routes
from animaltrack.web.routes.health import register_health_routes
from animaltrack.web.routes.move import register_move_routes
@@ -9,6 +10,7 @@ from animaltrack.web.routes.registry import register_registry_routes
__all__ = [
"register_egg_routes",
"register_events_routes",
"register_feed_routes",
"register_health_routes",
"register_move_routes",

View File

@@ -13,7 +13,7 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import ProductCollectedPayload
from animaltrack.events.store import EventStore
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
@@ -108,6 +108,7 @@ async def product_collected(request: Request):
registry.register(EventAnimalsProjection(db))
registry.register(IntervalProjection(db))
registry.register(ProductsProjection(db))
registry.register(EventLogProjection(db))
product_service = ProductService(db, event_store, registry)

View File

@@ -0,0 +1,101 @@
# ABOUTME: Routes for event log functionality.
# ABOUTME: Handles GET /event-log for viewing location event history.
from __future__ import annotations
import json
from typing import Any
from fasthtml.common import to_xml
from starlette.requests import Request
from starlette.responses import HTMLResponse
from animaltrack.repositories.locations import LocationRepository
from animaltrack.web.templates import page
from animaltrack.web.templates.events import event_log_list, event_log_panel
def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str, Any]]:
"""Get event log entries for a location.
Args:
db: Database connection.
location_id: Location ID to get events for.
limit: Maximum number of events to return.
Returns:
List of event log entries, newest first.
"""
rows = db.execute(
"""
SELECT event_id, location_id, ts_utc, type, actor, summary
FROM event_log_by_location
WHERE location_id = ?
ORDER BY ts_utc DESC
LIMIT ?
""",
(location_id, limit),
).fetchall()
events = []
for row in rows:
events.append(
{
"event_id": row[0],
"location_id": row[1],
"ts_utc": row[2],
"type": row[3],
"actor": row[4],
"summary": json.loads(row[5]),
}
)
return events
def event_log_index(request: Request):
"""GET /event-log - Event log for a location."""
db = request.app.state.db
# Get location_id from query params
location_id = request.query_params.get("location_id")
if not location_id:
return HTMLResponse(
content="<p>Missing location_id parameter</p>",
status_code=422,
)
# Get location name
location_repo = LocationRepository(db)
locations = location_repo.list_active()
location_name = "Unknown"
for loc in locations:
if loc.id == location_id:
location_name = loc.name
break
# Get event log
events = get_event_log(db, location_id)
# Check if HTMX request
is_htmx = request.headers.get("HX-Request") == "true"
if is_htmx:
# Return partial - just the event list
return HTMLResponse(content=to_xml(event_log_list(events)))
# Full page render
return page(
event_log_panel(events, location_name),
title=f"Event Log - {location_name}",
active_nav=None,
)
def register_events_routes(rt, app) -> None:
"""Register event log routes.
Args:
rt: FastHTML route decorator.
app: FastHTML app instance (unused, for consistency).
"""
rt("/event-log")(event_log_index)

View File

@@ -12,7 +12,7 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
from animaltrack.events.store import EventStore
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.feed import FeedInventoryProjection
from animaltrack.repositories.feed_types import FeedTypeRepository
from animaltrack.repositories.locations import LocationRepository
@@ -133,6 +133,7 @@ async def feed_given(request: Request):
event_store = EventStore(db)
registry = ProjectionRegistry()
registry.register(FeedInventoryProjection(db))
registry.register(EventLogProjection(db))
feed_service = FeedService(db, event_store, registry)

View File

@@ -13,7 +13,7 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import AnimalMovedPayload
from animaltrack.events.store import EventStore
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
@@ -216,6 +216,7 @@ async def animal_move(request: Request):
registry.register(AnimalRegistryProjection(db))
registry.register(EventAnimalsProjection(db))
registry.register(IntervalProjection(db))
registry.register(EventLogProjection(db))
animal_service = AnimalService(db, event_store, registry)

View File

@@ -0,0 +1,130 @@
# ABOUTME: Templates for the event log view.
# ABOUTME: Renders event log entries for a location with timeline styling.
from datetime import UTC, datetime
from typing import Any
from fasthtml.common import H3, Div, Li, P, Span, Ul
def format_timestamp(ts_utc: int) -> str:
"""Format a timestamp as a human-readable string."""
dt = datetime.fromtimestamp(ts_utc / 1000, tz=UTC)
return dt.strftime("%Y-%m-%d %H:%M")
def format_event_summary(event_type: str, summary: dict[str, Any]) -> str:
"""Format event summary for display."""
if event_type == "ProductCollected":
product = summary.get("product_code", "product")
qty = summary.get("quantity", 0)
return f"Collected {qty} {product}"
if event_type == "AnimalCohortCreated":
species = summary.get("species", "animals")
count = summary.get("count", 0)
origin = summary.get("origin", "unknown")
return f"Created cohort: {count} {species} ({origin})"
if event_type == "FeedGiven":
feed_type = summary.get("feed_type_code", "feed")
amount = summary.get("amount_kg", 0)
return f"Fed {amount}kg {feed_type}"
if event_type == "AnimalMoved":
count = summary.get("animal_count", 0)
return f"Moved {count} animal(s) here"
if event_type == "HatchRecorded":
species = summary.get("species", "")
count = summary.get("hatched_live", 0)
return f"Hatched {count} {species}"
if event_type == "AnimalTagged":
tag = summary.get("tag", "")
count = summary.get("animal_count", 0)
return f"Tagged {count} animal(s) as '{tag}'"
if event_type == "AnimalTagEnded":
tag = summary.get("tag", "")
count = summary.get("animal_count", 0)
return f"Removed tag '{tag}' from {count} animal(s)"
if event_type == "AnimalOutcome":
outcome = summary.get("outcome", "unknown")
count = summary.get("animal_count", 0)
return f"{outcome.capitalize()}: {count} animal(s)"
# Fallback
return event_type
def event_type_badge_class(event_type: str) -> str:
"""Get badge color class for event type."""
type_colors = {
"ProductCollected": "bg-amber-100 text-amber-800",
"AnimalCohortCreated": "bg-green-100 text-green-800",
"FeedGiven": "bg-blue-100 text-blue-800",
"AnimalMoved": "bg-purple-100 text-purple-800",
"HatchRecorded": "bg-pink-100 text-pink-800",
"AnimalTagged": "bg-indigo-100 text-indigo-800",
"AnimalTagEnded": "bg-slate-100 text-slate-800",
"AnimalOutcome": "bg-red-100 text-red-800",
}
return type_colors.get(event_type, "bg-gray-100 text-gray-800")
def event_log_item(
event_id: str,
event_type: str,
ts_utc: int,
actor: str,
summary: dict[str, Any],
) -> Any:
"""Render a single event log item."""
badge_cls = event_type_badge_class(event_type)
summary_text = format_event_summary(event_type, summary)
time_str = format_timestamp(ts_utc)
return Li(
Div(
Span(event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls}"),
Span(time_str, cls="text-xs text-stone-500 ml-2"),
cls="flex items-center gap-2 mb-1",
),
P(summary_text, cls="text-sm text-stone-700"),
P(f"by {actor}", cls="text-xs text-stone-400"),
cls="py-3 border-b border-stone-200 last:border-0",
)
def event_log_list(events: list[dict[str, Any]]) -> Any:
"""Render the event log list."""
if not events:
return Div(
P("No events recorded at this location yet.", cls="text-stone-500 text-sm"),
cls="p-4 text-center",
)
items = [
event_log_item(
event_id=e["event_id"],
event_type=e["type"],
ts_utc=e["ts_utc"],
actor=e["actor"],
summary=e["summary"],
)
for e in events
]
return Ul(*items, cls="divide-y divide-stone-200")
def event_log_panel(events: list[dict[str, Any]], location_name: str) -> Any:
"""Render the full event log panel."""
return Div(
H3(f"Event Log - {location_name}", cls="text-lg font-semibold mb-4"),
event_log_list(events),
cls="bg-white rounded-lg shadow p-4",
id="event-log",
)