feat: add location tooltips, links, and detail page
- Add tooltips with location ID on hover for location names in event detail
- Make location names clickable links to /locations/{id} detail page
- Create location detail page showing location info, live animal count,
and recent events at that location
- Add public GET /locations/{id} route (existing admin routes unchanged)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
# ABOUTME: Routes for Location management functionality (admin-only).
|
# ABOUTME: Routes for Location management functionality.
|
||||||
# ABOUTME: Handles GET /locations and POST /actions/location-* routes.
|
# ABOUTME: Handles GET /locations, GET /locations/{id}, and POST /actions/location-* routes.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ from animaltrack.services.location import LocationService, ValidationError
|
|||||||
from animaltrack.web.auth import require_role
|
from animaltrack.web.auth import require_role
|
||||||
from animaltrack.web.responses import success_toast
|
from animaltrack.web.responses import success_toast
|
||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page
|
||||||
|
from animaltrack.web.templates.location_detail import location_detail_panel
|
||||||
from animaltrack.web.templates.locations import location_list, rename_form
|
from animaltrack.web.templates.locations import location_list, rename_form
|
||||||
|
|
||||||
# APIRouter for multi-file route organization
|
# APIRouter for multi-file route organization
|
||||||
@@ -33,8 +34,79 @@ def _get_location_service(db) -> LocationService:
|
|||||||
return LocationService(db, event_store, registry)
|
return LocationService(db, event_store, registry)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_events(db, location_id: str, limit: int = 10) -> list[dict]:
|
||||||
|
"""Get recent events for a location from the event log projection."""
|
||||||
|
rows = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT event_id, location_id, ts_utc, type, actor, summary
|
||||||
|
FROM event_log_by_location
|
||||||
|
WHERE location_id = ?
|
||||||
|
ORDER BY ts_utc DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(location_id, limit),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
events = []
|
||||||
|
for row in rows:
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"event_id": row[0],
|
||||||
|
"location_id": row[1],
|
||||||
|
"ts_utc": row[2],
|
||||||
|
"type": row[3],
|
||||||
|
"actor": row[4],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def _get_live_animal_count(db, location_id: str) -> int:
|
||||||
|
"""Get count of live animals at a location."""
|
||||||
|
row = db.execute(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) FROM live_animals_by_location
|
||||||
|
WHERE location_id = ?
|
||||||
|
""",
|
||||||
|
(location_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# GET /locations - Location List
|
# GET /locations/{id} - Location Detail (Public)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@ar("/locations/{location_id}")
|
||||||
|
async def location_detail(req: Request, location_id: str):
|
||||||
|
"""GET /locations/{id} - Public location detail page."""
|
||||||
|
db = req.app.state.db
|
||||||
|
|
||||||
|
# Handle admin rename route - check if it's the special /rename path
|
||||||
|
# This is handled by a separate route, so we don't need to worry about it here
|
||||||
|
|
||||||
|
location = LocationRepository(db).get(location_id)
|
||||||
|
|
||||||
|
if location is None:
|
||||||
|
return HTMLResponse(content="Location not found", status_code=404)
|
||||||
|
|
||||||
|
# Get recent events at this location
|
||||||
|
recent_events = _get_recent_events(db, location_id)
|
||||||
|
|
||||||
|
# Get live animal count
|
||||||
|
animal_count = _get_live_animal_count(db, location_id)
|
||||||
|
|
||||||
|
return render_page(
|
||||||
|
req,
|
||||||
|
location_detail_panel(location, recent_events, animal_count),
|
||||||
|
title=f"{location.name} - AnimalTrack",
|
||||||
|
active_nav=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GET /locations - Location List (Admin)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -159,12 +159,26 @@ def render_payload_items(
|
|||||||
elif event_type == "AnimalMoved":
|
elif event_type == "AnimalMoved":
|
||||||
from_loc = payload.get("from_location_id", "")
|
from_loc = payload.get("from_location_id", "")
|
||||||
to_loc = payload.get("to_location_id", "")
|
to_loc = payload.get("to_location_id", "")
|
||||||
from_name = location_names.get(from_loc, from_loc[:8] + "..." if from_loc else "")
|
if from_loc:
|
||||||
to_name = location_names.get(to_loc, to_loc[:8] + "..." if to_loc else "")
|
from_name = location_names.get(from_loc, from_loc[:8] + "...")
|
||||||
if from_name:
|
items.append(
|
||||||
items.append(payload_item("From", from_name))
|
payload_item_with_link(
|
||||||
if to_name:
|
"From",
|
||||||
items.append(payload_item("To", to_name))
|
from_name,
|
||||||
|
f"/locations/{from_loc}",
|
||||||
|
f"ID: {from_loc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if to_loc:
|
||||||
|
to_name = location_names.get(to_loc, to_loc[:8] + "...")
|
||||||
|
items.append(
|
||||||
|
payload_item_with_link(
|
||||||
|
"To",
|
||||||
|
to_name,
|
||||||
|
f"/locations/{to_loc}",
|
||||||
|
f"ID: {to_loc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
elif event_type == "AnimalTagged":
|
elif event_type == "AnimalTagged":
|
||||||
if "tag" in payload:
|
if "tag" in payload:
|
||||||
@@ -250,6 +264,48 @@ def payload_item(label: str, value: str) -> Div:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def payload_item_with_link(label: str, text: str, href: str, title: str) -> Div:
|
||||||
|
"""Payload item with a clickable link."""
|
||||||
|
return Div(
|
||||||
|
Span(label + ":", cls="text-stone-500 text-sm min-w-[100px]"),
|
||||||
|
A(
|
||||||
|
text,
|
||||||
|
href=href,
|
||||||
|
title=title,
|
||||||
|
cls="text-amber-500 hover:underline text-sm",
|
||||||
|
),
|
||||||
|
cls="flex gap-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def location_display(
|
||||||
|
location_id: str,
|
||||||
|
location_names: dict[str, str],
|
||||||
|
as_link: bool = True,
|
||||||
|
):
|
||||||
|
"""Render a location ID with name and tooltip.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location_id: The location ULID.
|
||||||
|
location_names: Map of location IDs to names.
|
||||||
|
as_link: Whether to render as a link (default True).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A or Span element displaying the location name with ID tooltip.
|
||||||
|
"""
|
||||||
|
name = location_names.get(location_id, location_id[:8] + "...")
|
||||||
|
tooltip = f"ID: {location_id}"
|
||||||
|
|
||||||
|
if as_link:
|
||||||
|
return A(
|
||||||
|
name,
|
||||||
|
href=f"/locations/{location_id}",
|
||||||
|
title=tooltip,
|
||||||
|
cls="text-amber-500 hover:underline text-sm",
|
||||||
|
)
|
||||||
|
return Span(name, title=tooltip, cls="text-stone-300 text-sm")
|
||||||
|
|
||||||
|
|
||||||
def entity_refs_section(
|
def entity_refs_section(
|
||||||
entity_refs: dict[str, Any],
|
entity_refs: dict[str, Any],
|
||||||
location_names: dict[str, str],
|
location_names: dict[str, str],
|
||||||
@@ -264,15 +320,27 @@ def entity_refs_section(
|
|||||||
if key == "animal_ids":
|
if key == "animal_ids":
|
||||||
continue
|
continue
|
||||||
|
|
||||||
display_value = value
|
# Handle location references with links and tooltips
|
||||||
# Resolve location names
|
if (key.endswith("_location_id") or key == "location_id") and isinstance(value, str):
|
||||||
if key.endswith("_location_id") or key == "location_id":
|
loc_name = location_names.get(value, value[:8] + "...")
|
||||||
display_value = location_names.get(value, value[:8] + "..." if value else "")
|
items.append(
|
||||||
|
payload_item_with_link(
|
||||||
|
key.replace("_", " ").title(),
|
||||||
|
loc_name,
|
||||||
|
f"/locations/{value}",
|
||||||
|
f"ID: {value}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Handle lists
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
display_value = f"{len(value)} items"
|
display_value = f"{len(value)} items"
|
||||||
|
# Handle long strings
|
||||||
elif isinstance(value, str) and len(value) > 20:
|
elif isinstance(value, str) and len(value) > 20:
|
||||||
display_value = value[:8] + "..."
|
display_value = value[:8] + "..."
|
||||||
|
else:
|
||||||
|
display_value = value
|
||||||
|
|
||||||
items.append(payload_item(key.replace("_", " ").title(), str(display_value)))
|
items.append(payload_item(key.replace("_", " ").title(), str(display_value)))
|
||||||
|
|
||||||
|
|||||||
122
src/animaltrack/web/templates/location_detail.py
Normal file
122
src/animaltrack/web/templates/location_detail.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# ABOUTME: Template for location detail page.
|
||||||
|
# ABOUTME: Shows location information, status, and recent events.
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fasthtml.common import H1, H2, A, Div, Li, P, Span, Ul
|
||||||
|
|
||||||
|
from animaltrack.models.reference import Location
|
||||||
|
|
||||||
|
|
||||||
|
def format_timestamp(ts_utc: int) -> str:
|
||||||
|
"""Format timestamp for display."""
|
||||||
|
dt = datetime.fromtimestamp(ts_utc / 1000, tz=UTC)
|
||||||
|
return dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def location_detail_panel(
|
||||||
|
location: Location,
|
||||||
|
recent_events: list[dict[str, Any]] | None = None,
|
||||||
|
animal_count: int = 0,
|
||||||
|
) -> Div:
|
||||||
|
"""Location detail page content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
location: The location to display.
|
||||||
|
recent_events: Optional list of recent events at this location.
|
||||||
|
animal_count: Number of live animals currently at this location.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Div containing the location detail page.
|
||||||
|
"""
|
||||||
|
if recent_events is None:
|
||||||
|
recent_events = []
|
||||||
|
|
||||||
|
status_badge = (
|
||||||
|
Span("Active", cls="text-sm bg-green-900/50 text-green-300 px-2 py-1 rounded")
|
||||||
|
if location.active
|
||||||
|
else Span("Archived", cls="text-sm bg-stone-700 text-stone-400 px-2 py-1 rounded")
|
||||||
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
# Header
|
||||||
|
Div(
|
||||||
|
H1(location.name, cls="text-2xl font-bold text-stone-100"),
|
||||||
|
status_badge,
|
||||||
|
cls="flex items-center gap-4 mb-6",
|
||||||
|
),
|
||||||
|
# Info card
|
||||||
|
Div(
|
||||||
|
info_row("Location ID", location.id, monospace=True),
|
||||||
|
info_row("Created", format_timestamp(location.created_at_utc)),
|
||||||
|
info_row("Last Updated", format_timestamp(location.updated_at_utc)),
|
||||||
|
info_row("Live Animals", str(animal_count)),
|
||||||
|
cls="bg-stone-900/50 rounded-lg p-4 space-y-2 mb-6",
|
||||||
|
),
|
||||||
|
# Recent events section
|
||||||
|
recent_events_section(recent_events) if recent_events else Div(),
|
||||||
|
# Back link
|
||||||
|
Div(
|
||||||
|
A(
|
||||||
|
"← Back to Event Log",
|
||||||
|
href="/event-log",
|
||||||
|
cls="text-amber-500 hover:underline",
|
||||||
|
),
|
||||||
|
cls="mt-6",
|
||||||
|
),
|
||||||
|
cls="max-w-2xl",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def info_row(label: str, value: str, monospace: bool = False) -> Div:
|
||||||
|
"""Single info row with label and value."""
|
||||||
|
value_cls = "text-stone-200"
|
||||||
|
if monospace:
|
||||||
|
value_cls += " font-mono text-sm"
|
||||||
|
return Div(
|
||||||
|
Span(label + ":", cls="text-stone-500 min-w-[120px]"),
|
||||||
|
Span(value, cls=value_cls),
|
||||||
|
cls="flex gap-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def recent_events_section(events: list[dict[str, Any]]) -> Div:
|
||||||
|
"""Section showing recent events at this location."""
|
||||||
|
event_items = []
|
||||||
|
for event in events[:10]: # Limit to 10 most recent
|
||||||
|
event_items.append(
|
||||||
|
Li(
|
||||||
|
A(
|
||||||
|
Span(
|
||||||
|
event.get("type", "Unknown"),
|
||||||
|
cls="text-amber-500 hover:underline",
|
||||||
|
),
|
||||||
|
Span(
|
||||||
|
f" - {format_timestamp(event.get('ts_utc', 0))}",
|
||||||
|
cls="text-stone-500 text-sm",
|
||||||
|
),
|
||||||
|
href=f"/events/{event.get('event_id')}",
|
||||||
|
hx_get=f"/events/{event.get('event_id')}",
|
||||||
|
hx_target="#event-panel",
|
||||||
|
hx_swap="innerHTML",
|
||||||
|
),
|
||||||
|
cls="py-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not event_items:
|
||||||
|
return Div(
|
||||||
|
H2("Recent Events", cls="text-lg font-semibold text-stone-300 mb-2"),
|
||||||
|
P("No events recorded at this location.", cls="text-stone-500"),
|
||||||
|
cls="mt-4",
|
||||||
|
)
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
H2(
|
||||||
|
f"Recent Events ({len(events)})",
|
||||||
|
cls="text-lg font-semibold text-stone-300 mb-2",
|
||||||
|
),
|
||||||
|
Ul(*event_items, cls="space-y-1"),
|
||||||
|
cls="mt-4",
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user