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 fasthtml.common import APIRouter, to_xml
|
||||||
from starlette.requests import Request
|
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.events.store import EventStore
|
||||||
|
from animaltrack.models.reference import UserRole
|
||||||
from animaltrack.repositories.locations import LocationRepository
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||||
from animaltrack.web.templates import render_page
|
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
|
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")
|
@ar("/event-log")
|
||||||
def event_log_index(request: Request):
|
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
|
db = request.app.state.db
|
||||||
|
|
||||||
# Get username for user defaults lookup
|
# Get username for user defaults lookup
|
||||||
auth = request.scope.get("auth")
|
auth = request.scope.get("auth")
|
||||||
username = auth.username if auth else None
|
username = auth.username if auth else None
|
||||||
|
|
||||||
# Get location_id from query params
|
# Get filter params from query
|
||||||
location_id = request.query_params.get("location_id")
|
location_id = request.query_params.get("location_id", "")
|
||||||
|
event_type = request.query_params.get("event_type", "")
|
||||||
|
|
||||||
# If no query param, try user defaults
|
# "all" means show all events (no location filter)
|
||||||
if not location_id and username:
|
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")
|
defaults = UserDefaultsRepository(db).get(username, "event_log")
|
||||||
if defaults:
|
if defaults and defaults.location_id:
|
||||||
location_id = defaults.location_id
|
location_id = defaults.location_id
|
||||||
|
show_all = False
|
||||||
|
|
||||||
# Get all locations for selector
|
# Get all locations for selector
|
||||||
location_repo = LocationRepository(db)
|
location_repo = LocationRepository(db)
|
||||||
locations = location_repo.list_active()
|
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
|
location_name = None
|
||||||
if location_id:
|
if location_id and location_id != "all":
|
||||||
for loc in locations:
|
for loc in locations:
|
||||||
if loc.id == location_id:
|
if loc.id == location_id:
|
||||||
location_name = loc.name
|
location_name = loc.name
|
||||||
break
|
break
|
||||||
|
|
||||||
# Get event log if we have a valid location
|
# Get events based on filter
|
||||||
events = []
|
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)
|
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
|
# Check if HTMX request
|
||||||
is_htmx = request.headers.get("HX-Request") == "true"
|
is_htmx = request.headers.get("HX-Request") == "true"
|
||||||
@@ -103,7 +237,7 @@ def event_log_index(request: Request):
|
|||||||
# Full page render
|
# Full page render
|
||||||
return render_page(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
event_log_panel(events, locations, location_id),
|
event_log_panel(events, locations, location_id, event_type),
|
||||||
title="Event Log - AnimalTrack",
|
title="Event Log - AnimalTrack",
|
||||||
active_nav="event_log",
|
active_nav="event_log",
|
||||||
)
|
)
|
||||||
@@ -172,7 +306,94 @@ def event_detail(request: Request, event_id: str):
|
|||||||
if loc:
|
if loc:
|
||||||
location_names[loc_id] = loc.name
|
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 slide-over panel HTML
|
||||||
return HTMLResponse(
|
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
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
# Wrapped in a container with ID for HTMX targeting
|
# Wrapped in a container with ID for HTMX targeting
|
||||||
selection_content = None
|
selection_content = None
|
||||||
subset_mode = False
|
|
||||||
if animals and len(animals) > 1:
|
if animals and len(animals) > 1:
|
||||||
# Show checkbox list for subset selection
|
# Show checkbox list for subset selection
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||||
animal_checkbox_list(animals, resolved_ids),
|
animal_checkbox_list(animals, resolved_ids),
|
||||||
)
|
)
|
||||||
subset_mode = True
|
|
||||||
elif resolved_count > 0:
|
elif resolved_count > 0:
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P(
|
P(
|
||||||
@@ -641,7 +639,6 @@ def tag_add_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Add Tag", type="submit", cls=ButtonT.primary),
|
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
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
# Wrapped in a container with ID for HTMX targeting
|
# Wrapped in a container with ID for HTMX targeting
|
||||||
selection_content = None
|
selection_content = None
|
||||||
subset_mode = False
|
|
||||||
if animals and len(animals) > 1:
|
if animals and len(animals) > 1:
|
||||||
# Show checkbox list for subset selection
|
# Show checkbox list for subset selection
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||||
animal_checkbox_list(animals, resolved_ids),
|
animal_checkbox_list(animals, resolved_ids),
|
||||||
)
|
)
|
||||||
subset_mode = True
|
|
||||||
elif resolved_count > 0:
|
elif resolved_count > 0:
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P(
|
P(
|
||||||
@@ -864,7 +859,6 @@ def tag_end_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
|
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
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
# Wrapped in a container with ID for HTMX targeting
|
# Wrapped in a container with ID for HTMX targeting
|
||||||
selection_content = None
|
selection_content = None
|
||||||
subset_mode = False
|
|
||||||
if animals and len(animals) > 1:
|
if animals and len(animals) > 1:
|
||||||
# Show checkbox list for subset selection
|
# Show checkbox list for subset selection
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||||
animal_checkbox_list(animals, resolved_ids),
|
animal_checkbox_list(animals, resolved_ids),
|
||||||
)
|
)
|
||||||
subset_mode = True
|
|
||||||
elif resolved_count > 0:
|
elif resolved_count > 0:
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P(
|
P(
|
||||||
@@ -1111,7 +1103,6 @@ def attrs_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Update Attributes", type="submit", cls=ButtonT.primary),
|
Button("Update Attributes", type="submit", cls=ButtonT.primary),
|
||||||
@@ -1257,14 +1248,12 @@ def outcome_form(
|
|||||||
# Selection component - show checkboxes if animals provided and > 1
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
# Wrapped in a container with ID for HTMX targeting
|
# Wrapped in a container with ID for HTMX targeting
|
||||||
selection_content = None
|
selection_content = None
|
||||||
subset_mode = False
|
|
||||||
if animals and len(animals) > 1:
|
if animals and len(animals) > 1:
|
||||||
# Show checkbox list for subset selection
|
# Show checkbox list for subset selection
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||||
animal_checkbox_list(animals, resolved_ids),
|
animal_checkbox_list(animals, resolved_ids),
|
||||||
)
|
)
|
||||||
subset_mode = True
|
|
||||||
elif resolved_count > 0:
|
elif resolved_count > 0:
|
||||||
# Fallback to simple count display
|
# Fallback to simple count display
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
@@ -1408,7 +1397,6 @@ def outcome_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
|
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",
|
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(
|
return Div(
|
||||||
count_display,
|
count_display,
|
||||||
Div(
|
Div(
|
||||||
*items,
|
*items,
|
||||||
cls="max-h-64 overflow-y-auto",
|
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
|
# Hidden field to indicate subset selection mode
|
||||||
Input(type="hidden", name="subset_mode", value="true"),
|
Input(type="hidden", name="subset_mode", value="true"),
|
||||||
# Hidden field for roster_hash - will be updated via JS
|
# 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
|
// HTMX event: after loading event content, open the panel
|
||||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
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();
|
openEventPanel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,9 +4,10 @@
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
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.events import Event
|
||||||
|
from animaltrack.models.reference import UserRole
|
||||||
|
|
||||||
|
|
||||||
def format_timestamp(ts_utc: int) -> str:
|
def format_timestamp(ts_utc: int) -> str:
|
||||||
@@ -20,6 +21,7 @@ def event_detail_panel(
|
|||||||
affected_animals: list[dict[str, Any]],
|
affected_animals: list[dict[str, Any]],
|
||||||
is_tombstoned: bool = False,
|
is_tombstoned: bool = False,
|
||||||
location_names: dict[str, str] | None = None,
|
location_names: dict[str, str] | None = None,
|
||||||
|
user_role: UserRole | None = None,
|
||||||
) -> Div:
|
) -> Div:
|
||||||
"""Event detail slide-over panel content.
|
"""Event detail slide-over panel content.
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ def event_detail_panel(
|
|||||||
affected_animals: List of animals affected by this event.
|
affected_animals: List of animals affected by this event.
|
||||||
is_tombstoned: Whether the event has been deleted.
|
is_tombstoned: Whether the event has been deleted.
|
||||||
location_names: Map of location IDs to names.
|
location_names: Map of location IDs to names.
|
||||||
|
user_role: User's role for delete button visibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Div containing the panel content.
|
Div containing the panel content.
|
||||||
@@ -66,6 +69,8 @@ def event_detail_panel(
|
|||||||
entity_refs_section(event.entity_refs, location_names),
|
entity_refs_section(event.entity_refs, location_names),
|
||||||
# Affected animals
|
# Affected animals
|
||||||
affected_animals_section(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",
|
id="event-panel-content",
|
||||||
cls="bg-[#141413] h-full overflow-y-auto",
|
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"),
|
Ul(*animal_items, cls="space-y-1"),
|
||||||
cls="p-4",
|
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)
|
count = summary.get("animal_count", 0)
|
||||||
return f"{outcome.capitalize()}: {count} animal(s)"
|
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
|
# Fallback
|
||||||
return event_type
|
return event_type
|
||||||
|
|
||||||
@@ -65,11 +75,13 @@ def event_type_badge_class(event_type: str) -> str:
|
|||||||
"ProductCollected": "bg-amber-100 text-amber-800",
|
"ProductCollected": "bg-amber-100 text-amber-800",
|
||||||
"AnimalCohortCreated": "bg-green-100 text-green-800",
|
"AnimalCohortCreated": "bg-green-100 text-green-800",
|
||||||
"FeedGiven": "bg-blue-100 text-blue-800",
|
"FeedGiven": "bg-blue-100 text-blue-800",
|
||||||
|
"FeedPurchased": "bg-cyan-100 text-cyan-800",
|
||||||
"AnimalMoved": "bg-purple-100 text-purple-800",
|
"AnimalMoved": "bg-purple-100 text-purple-800",
|
||||||
"HatchRecorded": "bg-pink-100 text-pink-800",
|
"HatchRecorded": "bg-pink-100 text-pink-800",
|
||||||
"AnimalTagged": "bg-indigo-100 text-indigo-800",
|
"AnimalTagged": "bg-indigo-100 text-indigo-800",
|
||||||
"AnimalTagEnded": "bg-slate-100 text-slate-800",
|
"AnimalTagEnded": "bg-slate-100 text-slate-800",
|
||||||
"AnimalOutcome": "bg-red-100 text-red-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")
|
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(summary_text, cls="text-sm text-stone-700"),
|
||||||
P(f"by {actor}", cls="text-xs text-stone-400"),
|
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."""
|
"""Render the event log list."""
|
||||||
if not events:
|
if not events:
|
||||||
return Div(
|
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",
|
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")
|
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."""
|
"""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:
|
for loc in locations:
|
||||||
options.append(Option(loc.name, value=loc.id, selected=loc.id == selected_location_id))
|
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(
|
Select(
|
||||||
*options,
|
*options,
|
||||||
name="location_id",
|
name="location_id",
|
||||||
|
id="location-filter",
|
||||||
cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm "
|
cls="mt-1 block w-full rounded-md border-stone-300 shadow-sm "
|
||||||
"focus:border-amber-500 focus:ring-amber-500 sm:text-sm",
|
"focus:border-amber-500 focus:ring-amber-500 sm:text-sm",
|
||||||
hx_get="/event-log",
|
hx_get="/event-log",
|
||||||
hx_trigger="change",
|
hx_trigger="change",
|
||||||
hx_target="#event-log-content",
|
hx_target="#event-log-content",
|
||||||
hx_swap="innerHTML",
|
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(
|
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:
|
) -> Any:
|
||||||
"""Render the full event log panel."""
|
"""Render the full event log panel."""
|
||||||
# Find location name for header
|
# Find location name for header
|
||||||
location_name = None
|
location_name = None
|
||||||
if selected_location_id:
|
if selected_location_id and selected_location_id not in ("", "all"):
|
||||||
for loc in locations:
|
for loc in locations:
|
||||||
if loc.id == selected_location_id:
|
if loc.id == selected_location_id:
|
||||||
location_name = loc.name
|
location_name = loc.name
|
||||||
@@ -159,14 +222,13 @@ def event_log_panel(
|
|||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
H3(header_text, cls="text-lg font-semibold mb-4"),
|
H3(header_text, cls="text-lg font-semibold mb-4"),
|
||||||
location_selector(locations, selected_location_id),
|
|
||||||
Div(
|
Div(
|
||||||
event_log_list(events)
|
location_selector(locations, selected_location_id, selected_event_type),
|
||||||
if selected_location_id
|
event_type_selector(selected_event_type),
|
||||||
else P(
|
cls="flex gap-4 mb-4",
|
||||||
"Select a location to view events.",
|
),
|
||||||
cls="text-stone-500 text-sm text-center py-4",
|
Div(
|
||||||
),
|
event_log_list(events),
|
||||||
id="event-log-content",
|
id="event-log-content",
|
||||||
),
|
),
|
||||||
cls="bg-white rounded-lg shadow p-4",
|
cls="bg-white rounded-lg shadow p-4",
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ def move_form(
|
|||||||
# Selection component - show checkboxes if animals provided and > 1
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
# Wrapped in a container with ID for HTMX targeting
|
# Wrapped in a container with ID for HTMX targeting
|
||||||
selection_content = None
|
selection_content = None
|
||||||
subset_mode = False
|
|
||||||
if animals and len(animals) > 1:
|
if animals and len(animals) > 1:
|
||||||
# Show checkbox list for subset selection
|
# Show checkbox list for subset selection
|
||||||
location_info = f" from {from_location_name}" if from_location_name else ""
|
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"),
|
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
|
||||||
animal_checkbox_list(animals, resolved_ids),
|
animal_checkbox_list(animals, resolved_ids),
|
||||||
)
|
)
|
||||||
subset_mode = True
|
|
||||||
elif resolved_count > 0:
|
elif resolved_count > 0:
|
||||||
location_info = f" from {from_location_name}" if from_location_name else ""
|
location_info = f" from {from_location_name}" if from_location_name else ""
|
||||||
selection_content = Div(
|
selection_content = Div(
|
||||||
@@ -145,7 +143,6 @@ def move_form(
|
|||||||
Hidden(name="from_location_id", value=from_location_id or ""),
|
Hidden(name="from_location_id", value=from_location_id or ""),
|
||||||
Hidden(name="resolver_version", value="v1"),
|
Hidden(name="resolver_version", value="v1"),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Move Animals", type="submit", cls=ButtonT.primary),
|
Button("Move Animals", type="submit", cls=ButtonT.primary),
|
||||||
|
|||||||
@@ -108,11 +108,11 @@ class TestEventLogRoute:
|
|||||||
"""Tests for GET /event-log route."""
|
"""Tests for GET /event-log route."""
|
||||||
|
|
||||||
def test_event_log_without_location_shows_selector(self, client):
|
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")
|
response = client.get("/event-log")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
# Should show location selector prompt
|
# Should show location selector with "All locations" option
|
||||||
assert "Select a location" in response.text
|
assert "All locations" in response.text
|
||||||
|
|
||||||
def test_event_log_returns_empty_for_new_location(self, client, valid_location_id):
|
def test_event_log_returns_empty_for_new_location(self, client, valid_location_id):
|
||||||
"""Event log returns empty state for location with no events."""
|
"""Event log returns empty state for location with no events."""
|
||||||
|
|||||||
Reference in New Issue
Block a user