diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py
index aafa355..c585a63 100644
--- a/src/animaltrack/web/routes/events.py
+++ b/src/animaltrack/web/routes/events.py
@@ -8,9 +8,17 @@ from typing import Any
from fasthtml.common import APIRouter, to_xml
from starlette.requests import Request
-from starlette.responses import HTMLResponse
+from starlette.responses import HTMLResponse, JSONResponse
+from animaltrack.events.delete import delete_event
+from animaltrack.events.dependencies import find_dependent_events
+from animaltrack.events.exceptions import (
+ DependentEventsError,
+ EventNotFoundError,
+ EventTombstonedError,
+)
from animaltrack.events.store import EventStore
+from animaltrack.models.reference import UserRole
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.user_defaults import UserDefaultsRepository
from animaltrack.web.templates import render_page
@@ -58,40 +66,166 @@ def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str,
return events
+def get_all_events(
+ db: Any,
+ event_type: str | None = None,
+ limit: int = 100,
+) -> list[dict[str, Any]]:
+ """Get all events from the events table.
+
+ This queries the main events table directly, so it includes
+ events that are not location-scoped (AnimalOutcome, FeedPurchased, etc.).
+
+ Args:
+ db: Database connection.
+ event_type: Optional event type filter.
+ limit: Maximum number of events to return.
+
+ Returns:
+ List of event entries, newest first.
+ """
+ if event_type:
+ rows = db.execute(
+ """
+ SELECT id, ts_utc, type, actor, payload
+ FROM events
+ WHERE type = ?
+ ORDER BY ts_utc DESC
+ LIMIT ?
+ """,
+ (event_type, limit),
+ ).fetchall()
+ else:
+ rows = db.execute(
+ """
+ SELECT id, ts_utc, type, actor, payload
+ FROM events
+ ORDER BY ts_utc DESC
+ LIMIT ?
+ """,
+ (limit,),
+ ).fetchall()
+
+ events = []
+ for row in rows:
+ payload = json.loads(row[4])
+ # Build a summary from the payload
+ summary = _build_summary_from_payload(row[2], payload)
+ events.append(
+ {
+ "event_id": row[0],
+ "location_id": None, # Not always present
+ "ts_utc": row[1],
+ "type": row[2],
+ "actor": row[3],
+ "summary": summary,
+ }
+ )
+ return events
+
+
+def _build_summary_from_payload(event_type: str, payload: dict[str, Any]) -> dict[str, Any]:
+ """Build a display summary from event payload."""
+ if event_type == "AnimalCohortCreated":
+ return {
+ "species": payload.get("species"),
+ "count": payload.get("count"),
+ "origin": payload.get("origin"),
+ }
+ if event_type == "AnimalMoved":
+ return {"animal_count": len(payload.get("resolved_ids", []))}
+ if event_type == "AnimalTagged":
+ return {
+ "tag": payload.get("tag"),
+ "animal_count": len(payload.get("resolved_ids", [])),
+ }
+ if event_type == "AnimalTagEnded":
+ return {
+ "tag": payload.get("tag"),
+ "animal_count": len(payload.get("resolved_ids", [])),
+ }
+ if event_type == "AnimalOutcome":
+ return {
+ "outcome": payload.get("outcome"),
+ "animal_count": len(payload.get("resolved_ids", [])),
+ }
+ if event_type == "ProductCollected":
+ return {
+ "product_code": payload.get("product_code"),
+ "quantity": payload.get("quantity"),
+ }
+ if event_type == "FeedGiven":
+ kg = payload.get("quantity_grams", 0) / 1000 if payload.get("quantity_grams") else 0
+ return {
+ "feed_type_code": payload.get("feed_code"),
+ "amount_kg": round(kg, 3),
+ }
+ if event_type == "FeedPurchased":
+ kg = payload.get("quantity_grams", 0) / 1000 if payload.get("quantity_grams") else 0
+ return {
+ "feed_code": payload.get("feed_code"),
+ "amount_kg": round(kg, 3),
+ }
+ if event_type == "ProductSold":
+ return {
+ "product_code": payload.get("product_code"),
+ "quantity": payload.get("quantity"),
+ }
+ if event_type == "HatchRecorded":
+ return {
+ "species": payload.get("species"),
+ "hatched_live": payload.get("hatched_live"),
+ }
+ # Fallback
+ return {"event_type": event_type}
+
+
@ar("/event-log")
def event_log_index(request: Request):
- """GET /event-log - Event log for a location."""
+ """GET /event-log - Event log for all events or filtered by location/type."""
db = request.app.state.db
# Get username for user defaults lookup
auth = request.scope.get("auth")
username = auth.username if auth else None
- # Get location_id from query params
- location_id = request.query_params.get("location_id")
+ # Get filter params from query
+ location_id = request.query_params.get("location_id", "")
+ event_type = request.query_params.get("event_type", "")
- # If no query param, try user defaults
- if not location_id and username:
+ # "all" means show all events (no location filter)
+ show_all = location_id == "all" or location_id == ""
+
+ # If no query param and not explicitly "all", try user defaults
+ if not location_id and not event_type and username:
defaults = UserDefaultsRepository(db).get(username, "event_log")
- if defaults:
+ if defaults and defaults.location_id:
location_id = defaults.location_id
+ show_all = False
# Get all locations for selector
location_repo = LocationRepository(db)
locations = location_repo.list_active()
- # Find location name if we have a location_id
+ # Find location name if we have a specific location_id
location_name = None
- if location_id:
+ if location_id and location_id != "all":
for loc in locations:
if loc.id == location_id:
location_name = loc.name
break
- # Get event log if we have a valid location
+ # Get events based on filter
events = []
- if location_id and location_name:
+ if show_all or not location_id:
+ # Show all events (from main events table)
+ events = get_all_events(db, event_type=event_type or None)
+ elif location_id and location_name:
+ # Show events for specific location
events = get_event_log(db, location_id)
+ # Filter by event type if specified
+ if event_type:
+ events = [e for e in events if e["type"] == event_type]
# Check if HTMX request
is_htmx = request.headers.get("HX-Request") == "true"
@@ -103,7 +237,7 @@ def event_log_index(request: Request):
# Full page render
return render_page(
request,
- event_log_panel(events, locations, location_id),
+ event_log_panel(events, locations, location_id, event_type),
title="Event Log - AnimalTrack",
active_nav="event_log",
)
@@ -172,7 +306,94 @@ def event_detail(request: Request, event_id: str):
if loc:
location_names[loc_id] = loc.name
+ # Get user role for delete button visibility
+ auth = request.scope.get("auth")
+ user_role = auth.role if auth else None
+
# Return slide-over panel HTML
return HTMLResponse(
- content=to_xml(event_detail_panel(event, affected_animals, is_tombstoned, location_names)),
+ content=to_xml(
+ event_detail_panel(event, affected_animals, is_tombstoned, location_names, user_role)
+ ),
)
+
+
+@ar("/events/{event_id}/delete", methods=["POST"])
+async def event_delete(request: Request, event_id: str):
+ """POST /events/{event_id}/delete - Delete an event (admin only).
+
+ Form params:
+ - reason: Optional reason for deletion
+ - cascade: Whether to delete dependent events too
+
+ Returns JSON response indicating success or error.
+ """
+ db = request.app.state.db
+
+ # Check authorization
+ auth = request.scope.get("auth")
+ if not auth:
+ return JSONResponse({"error": "Not authenticated"}, status_code=401)
+
+ if auth.role != UserRole.admin:
+ return JSONResponse({"error": "Admin role required"}, status_code=403)
+
+ # Parse form data
+ form = await request.form()
+ reason = form.get("reason", "")
+ cascade = form.get("cascade", "false") == "true"
+
+ # Get event store and registry
+ event_store = EventStore(db)
+ registry = request.app.state.registry
+
+ try:
+ # Check for dependent events first
+ dependents = find_dependent_events(db, event_store, event_id)
+
+ if dependents and not cascade:
+ return JSONResponse(
+ {
+ "error": "has_dependents",
+ "dependent_count": len(dependents),
+ "message": f"This event has {len(dependents)} dependent event(s). "
+ "Delete them first or enable cascade delete.",
+ },
+ status_code=409,
+ )
+
+ # Perform the deletion
+ deleted_ids = delete_event(
+ db=db,
+ event_store=event_store,
+ event_id=event_id,
+ actor=auth.username,
+ role="admin",
+ cascade=cascade,
+ reason=reason or None,
+ registry=registry,
+ )
+
+ return JSONResponse(
+ {
+ "success": True,
+ "deleted_count": len(deleted_ids),
+ "deleted_ids": deleted_ids,
+ }
+ )
+
+ except EventNotFoundError:
+ return JSONResponse({"error": "Event not found"}, status_code=404)
+
+ except EventTombstonedError:
+ return JSONResponse({"error": "Event has already been deleted"}, status_code=409)
+
+ except DependentEventsError as e:
+ return JSONResponse(
+ {
+ "error": "has_dependents",
+ "dependent_count": len(e.dependents),
+ "message": str(e),
+ },
+ status_code=409,
+ )
diff --git a/src/animaltrack/web/templates/actions.py b/src/animaltrack/web/templates/actions.py
index 44f29cf..dd1bcae 100644
--- a/src/animaltrack/web/templates/actions.py
+++ b/src/animaltrack/web/templates/actions.py
@@ -568,14 +568,12 @@ def tag_add_form(
# Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting
selection_content = None
- subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
)
- subset_mode = True
elif resolved_count > 0:
selection_content = Div(
P(
@@ -641,7 +639,6 @@ def tag_add_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
- Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Add Tag", type="submit", cls=ButtonT.primary),
@@ -781,14 +778,12 @@ def tag_end_form(
# Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting
selection_content = None
- subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
)
- subset_mode = True
elif resolved_count > 0:
selection_content = Div(
P(
@@ -864,7 +859,6 @@ def tag_end_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
- Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
@@ -1000,14 +994,12 @@ def attrs_form(
# Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting
selection_content = None
- subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
)
- subset_mode = True
elif resolved_count > 0:
selection_content = Div(
P(
@@ -1111,7 +1103,6 @@ def attrs_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
- Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Update Attributes", type="submit", cls=ButtonT.primary),
@@ -1257,14 +1248,12 @@ def outcome_form(
# Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting
selection_content = None
- subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
selection_content = Div(
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
)
- subset_mode = True
elif resolved_count > 0:
# Fallback to simple count display
selection_content = Div(
@@ -1408,7 +1397,6 @@ def outcome_form(
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="confirmed", value=""),
- Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
diff --git a/src/animaltrack/web/templates/animal_select.py b/src/animaltrack/web/templates/animal_select.py
index 1bdc9a1..aeb232c 100644
--- a/src/animaltrack/web/templates/animal_select.py
+++ b/src/animaltrack/web/templates/animal_select.py
@@ -83,12 +83,19 @@ def animal_checkbox_list(
cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2",
)
+ # Hidden fields for all resolved IDs (needed for validation)
+ resolved_id_fields = [
+ Input(type="hidden", name="resolved_ids", value=a.animal_id) for a in animals
+ ]
+
return Div(
count_display,
Div(
*items,
cls="max-h-64 overflow-y-auto",
),
+ # Hidden fields for resolved_ids (all animals from filter resolution)
+ *resolved_id_fields,
# Hidden field to indicate subset selection mode
Input(type="hidden", name="subset_mode", value="true"),
# Hidden field for roster_hash - will be updated via JS
diff --git a/src/animaltrack/web/templates/base.py b/src/animaltrack/web/templates/base.py
index e387c6e..f5ab09d 100644
--- a/src/animaltrack/web/templates/base.py
+++ b/src/animaltrack/web/templates/base.py
@@ -60,7 +60,8 @@ def EventSlideOverScript(): # noqa: N802
// HTMX event: after loading event content, open the panel
document.body.addEventListener('htmx:afterSwap', function(evt) {
- if (evt.detail.target.id === 'event-slide-over') {
+ if (evt.detail.target.id === 'event-slide-over' ||
+ evt.detail.target.id === 'event-panel-content') {
openEventPanel();
}
});
diff --git a/src/animaltrack/web/templates/event_detail.py b/src/animaltrack/web/templates/event_detail.py
index 29d223d..ddcc5c4 100644
--- a/src/animaltrack/web/templates/event_detail.py
+++ b/src/animaltrack/web/templates/event_detail.py
@@ -4,9 +4,10 @@
from datetime import UTC, datetime
from typing import Any
-from fasthtml.common import H3, A, Button, Div, Li, P, Span, Ul
+from fasthtml.common import H3, A, Button, Div, Li, P, Script, Span, Ul
from animaltrack.models.events import Event
+from animaltrack.models.reference import UserRole
def format_timestamp(ts_utc: int) -> str:
@@ -20,6 +21,7 @@ def event_detail_panel(
affected_animals: list[dict[str, Any]],
is_tombstoned: bool = False,
location_names: dict[str, str] | None = None,
+ user_role: UserRole | None = None,
) -> Div:
"""Event detail slide-over panel content.
@@ -28,6 +30,7 @@ def event_detail_panel(
affected_animals: List of animals affected by this event.
is_tombstoned: Whether the event has been deleted.
location_names: Map of location IDs to names.
+ user_role: User's role for delete button visibility.
Returns:
Div containing the panel content.
@@ -66,6 +69,8 @@ def event_detail_panel(
entity_refs_section(event.entity_refs, location_names),
# Affected animals
affected_animals_section(affected_animals),
+ # Delete button (admin only, not for tombstoned events)
+ delete_section(event.id) if user_role == UserRole.admin and not is_tombstoned else None,
id="event-panel-content",
cls="bg-[#141413] h-full overflow-y-auto",
)
@@ -320,3 +325,109 @@ def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
Ul(*animal_items, cls="space-y-1"),
cls="p-4",
)
+
+
+def delete_section(event_id: str) -> Div:
+ """Section with delete button and confirmation dialog for admins."""
+ return Div(
+ # Delete button
+ Button(
+ "Delete Event",
+ type="button",
+ onclick="showDeleteConfirm()",
+ cls="w-full bg-red-800 hover:bg-red-700 text-white font-medium py-2 px-4 rounded",
+ ),
+ # Confirmation dialog (hidden by default)
+ Div(
+ Div(
+ P(
+ "Are you sure you want to delete this event?",
+ cls="text-stone-200 font-medium mb-2",
+ ),
+ P(
+ "This will reverse its effects on projections.",
+ cls="text-stone-400 text-sm mb-4",
+ ),
+ # Delete status message
+ Div(
+ id="delete-status",
+ cls="text-sm mb-4",
+ ),
+ # Buttons
+ Div(
+ Button(
+ "Cancel",
+ type="button",
+ onclick="hideDeleteConfirm()",
+ cls="bg-stone-700 hover:bg-stone-600 text-white font-medium py-2 px-4 rounded mr-2",
+ ),
+ Button(
+ "Delete",
+ type="button",
+ onclick=f"deleteEvent('{event_id}')",
+ id="confirm-delete-btn",
+ cls="bg-red-700 hover:bg-red-600 text-white font-medium py-2 px-4 rounded",
+ ),
+ cls="flex justify-end",
+ ),
+ cls="bg-stone-800 p-4 rounded-lg",
+ ),
+ id="delete-confirm-dialog",
+ cls="hidden",
+ ),
+ # JavaScript for delete functionality
+ delete_script(),
+ cls="p-4 border-t border-stone-700",
+ )
+
+
+def delete_script() -> Script:
+ """JavaScript for delete confirmation dialog."""
+ return Script("""
+ function showDeleteConfirm() {
+ document.getElementById('delete-confirm-dialog').classList.remove('hidden');
+ }
+
+ function hideDeleteConfirm() {
+ document.getElementById('delete-confirm-dialog').classList.add('hidden');
+ document.getElementById('delete-status').innerHTML = '';
+ }
+
+ async function deleteEvent(eventId) {
+ const statusEl = document.getElementById('delete-status');
+ const deleteBtn = document.getElementById('confirm-delete-btn');
+
+ statusEl.innerHTML = 'Deleting...';
+ deleteBtn.disabled = true;
+
+ try {
+ const response = await fetch('/events/' + eventId + '/delete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ },
+ body: 'reason=Deleted via UI'
+ });
+
+ const data = await response.json();
+
+ if (response.ok) {
+ statusEl.innerHTML = 'Event deleted successfully!';
+ // Close panel and refresh page after brief delay
+ setTimeout(function() {
+ closeEventPanel();
+ window.location.reload();
+ }, 1000);
+ } else if (data.error === 'has_dependents') {
+ statusEl.innerHTML = '' + data.message + '';
+ deleteBtn.disabled = false;
+ } else {
+ statusEl.innerHTML = 'Error: ' + (data.error || 'Unknown error') + '';
+ deleteBtn.disabled = false;
+ }
+ } catch (err) {
+ statusEl.innerHTML = 'Error: ' + err.message + '';
+ deleteBtn.disabled = false;
+ }
+ }
+ """)
diff --git a/src/animaltrack/web/templates/events.py b/src/animaltrack/web/templates/events.py
index e4211fc..1c6ee20 100644
--- a/src/animaltrack/web/templates/events.py
+++ b/src/animaltrack/web/templates/events.py
@@ -55,6 +55,16 @@ def format_event_summary(event_type: str, summary: dict[str, Any]) -> str:
count = summary.get("animal_count", 0)
return f"{outcome.capitalize()}: {count} animal(s)"
+ if event_type == "FeedPurchased":
+ feed_code = summary.get("feed_code", "feed")
+ amount = summary.get("amount_kg", 0)
+ return f"Purchased {amount}kg {feed_code}"
+
+ if event_type == "ProductSold":
+ product = summary.get("product_code", "product")
+ qty = summary.get("quantity", 0)
+ return f"Sold {qty} {product}"
+
# Fallback
return event_type
@@ -65,11 +75,13 @@ def event_type_badge_class(event_type: str) -> str:
"ProductCollected": "bg-amber-100 text-amber-800",
"AnimalCohortCreated": "bg-green-100 text-green-800",
"FeedGiven": "bg-blue-100 text-blue-800",
+ "FeedPurchased": "bg-cyan-100 text-cyan-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",
+ "ProductSold": "bg-emerald-100 text-emerald-800",
}
return type_colors.get(event_type, "bg-gray-100 text-gray-800")
@@ -94,7 +106,10 @@ def event_log_item(
),
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",
+ cls="py-3 border-b border-stone-200 last:border-0 cursor-pointer hover:bg-stone-50",
+ hx_get=f"/events/{event_id}",
+ hx_target="#event-panel-content",
+ hx_swap="innerHTML",
)
@@ -102,7 +117,7 @@ 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"),
+ P("No events found matching the current filters.", cls="text-stone-500 text-sm"),
cls="p-4 text-center",
)
@@ -120,9 +135,12 @@ def event_log_list(events: list[dict[str, Any]]) -> Any:
return Ul(*items, cls="divide-y divide-stone-200")
-def location_selector(locations: list[Any], selected_location_id: str | None) -> Any:
+def location_selector(
+ locations: list[Any], selected_location_id: str | None, selected_event_type: str = ""
+) -> Any:
"""Render location selector dropdown."""
- options = [Option("Select a location...", value="", selected=not selected_location_id)]
+ is_all = selected_location_id in ("", "all", None)
+ options = [Option("All locations", value="all", selected=is_all)]
for loc in locations:
options.append(Option(loc.name, value=loc.id, selected=loc.id == selected_location_id))
@@ -131,25 +149,70 @@ def location_selector(locations: list[Any], selected_location_id: str | None) ->
Select(
*options,
name="location_id",
+ id="location-filter",
cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm "
"focus:border-amber-500 focus:ring-amber-500 sm:text-sm",
hx_get="/event-log",
hx_trigger="change",
hx_target="#event-log-content",
hx_swap="innerHTML",
- hx_include="this",
+ hx_include="#event-type-filter",
),
- cls="mb-4 max-w-xs",
+ cls="max-w-xs",
+ )
+
+
+# List of known event types for the filter dropdown
+EVENT_TYPES = [
+ "AnimalCohortCreated",
+ "AnimalMoved",
+ "AnimalOutcome",
+ "AnimalTagEnded",
+ "AnimalTagged",
+ "FeedGiven",
+ "FeedPurchased",
+ "HatchRecorded",
+ "ProductCollected",
+ "ProductSold",
+]
+
+
+def event_type_selector(selected_event_type: str = "") -> Any:
+ """Render event type filter dropdown."""
+ options = [Option("All types", value="", selected=not selected_event_type)]
+ for event_type in EVENT_TYPES:
+ options.append(
+ Option(event_type, value=event_type, selected=event_type == selected_event_type)
+ )
+
+ return Div(
+ Label("Event Type", cls="text-sm font-medium text-stone-700"),
+ Select(
+ *options,
+ name="event_type",
+ id="event-type-filter",
+ cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm "
+ "focus:border-amber-500 focus:ring-amber-500 sm:text-sm",
+ hx_get="/event-log",
+ hx_trigger="change",
+ hx_target="#event-log-content",
+ hx_swap="innerHTML",
+ hx_include="#location-filter",
+ ),
+ cls="max-w-xs",
)
def event_log_panel(
- events: list[dict[str, Any]], locations: list[Any], selected_location_id: str | None
+ events: list[dict[str, Any]],
+ locations: list[Any],
+ selected_location_id: str | None,
+ selected_event_type: str = "",
) -> Any:
"""Render the full event log panel."""
# Find location name for header
location_name = None
- if selected_location_id:
+ if selected_location_id and selected_location_id not in ("", "all"):
for loc in locations:
if loc.id == selected_location_id:
location_name = loc.name
@@ -159,14 +222,13 @@ def event_log_panel(
return Div(
H3(header_text, cls="text-lg font-semibold mb-4"),
- location_selector(locations, selected_location_id),
Div(
- event_log_list(events)
- if selected_location_id
- else P(
- "Select a location to view events.",
- cls="text-stone-500 text-sm text-center py-4",
- ),
+ location_selector(locations, selected_location_id, selected_event_type),
+ event_type_selector(selected_event_type),
+ cls="flex gap-4 mb-4",
+ ),
+ Div(
+ event_log_list(events),
id="event-log-content",
),
cls="bg-white rounded-lg shadow p-4",
diff --git a/src/animaltrack/web/templates/move.py b/src/animaltrack/web/templates/move.py
index a94be6d..3cc9f5c 100644
--- a/src/animaltrack/web/templates/move.py
+++ b/src/animaltrack/web/templates/move.py
@@ -68,7 +68,6 @@ def move_form(
# Selection component - show checkboxes if animals provided and > 1
# Wrapped in a container with ID for HTMX targeting
selection_content = None
- subset_mode = False
if animals and len(animals) > 1:
# Show checkbox list for subset selection
location_info = f" from {from_location_name}" if from_location_name else ""
@@ -76,7 +75,6 @@ def move_form(
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
animal_checkbox_list(animals, resolved_ids),
)
- subset_mode = True
elif resolved_count > 0:
location_info = f" from {from_location_name}" if from_location_name else ""
selection_content = Div(
@@ -145,7 +143,6 @@ def move_form(
Hidden(name="from_location_id", value=from_location_id or ""),
Hidden(name="resolver_version", value="v1"),
Hidden(name="confirmed", value=""),
- Hidden(name="subset_mode", value="true" if subset_mode else ""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Move Animals", type="submit", cls=ButtonT.primary),
diff --git a/tests/test_web_events.py b/tests/test_web_events.py
index 0596759..8357522 100644
--- a/tests/test_web_events.py
+++ b/tests/test_web_events.py
@@ -108,11 +108,11 @@ class TestEventLogRoute:
"""Tests for GET /event-log route."""
def test_event_log_without_location_shows_selector(self, client):
- """Event log without location_id shows location selector."""
+ """Event log without location_id shows location selector with 'All locations' default."""
response = client.get("/event-log")
assert response.status_code == 200
- # Should show location selector prompt
- assert "Select a location" in response.text
+ # Should show location selector with "All locations" option
+ assert "All locations" in response.text
def test_event_log_returns_empty_for_new_location(self, client, valid_location_id):
"""Event log returns empty state for location with no events."""