fix: checkbox selection bug and add event log improvements
- Fix checkbox selection not working: remove duplicate subset_mode hidden fields from 5 form templates and add resolved_ids to checkbox component - Add all-events view to event log (shows events without location like AnimalOutcome, FeedPurchased, ProductSold) - Add event type filter dropdown alongside location filter - Make event log items clickable to open event detail slide-over - Add event delete UI with confirmation dialog (admin only) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 = '<span class="text-amber-400">Deleting...</span>';
|
||||
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 = '<span class="text-green-400">Event deleted successfully!</span>';
|
||||
// Close panel and refresh page after brief delay
|
||||
setTimeout(function() {
|
||||
closeEventPanel();
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} else if (data.error === 'has_dependents') {
|
||||
statusEl.innerHTML = '<span class="text-red-400">' + data.message + '</span>';
|
||||
deleteBtn.disabled = false;
|
||||
} else {
|
||||
statusEl.innerHTML = '<span class="text-red-400">Error: ' + (data.error || 'Unknown error') + '</span>';
|
||||
deleteBtn.disabled = false;
|
||||
}
|
||||
} catch (err) {
|
||||
statusEl.innerHTML = '<span class="text-red-400">Error: ' + err.message + '</span>';
|
||||
deleteBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
""")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user