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:
@@ -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",
|
||||
|
||||
140
src/animaltrack/projections/event_log.py
Normal file
140
src/animaltrack/projections/event_log.py
Normal 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}
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
101
src/animaltrack/web/routes/events.py
Normal file
101
src/animaltrack/web/routes/events.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
130
src/animaltrack/web/templates/events.py
Normal file
130
src/animaltrack/web/templates/events.py
Normal 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",
|
||||
)
|
||||
Reference in New Issue
Block a user