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: Handles GET /locations and POST /actions/location-* routes.
|
||||
# ABOUTME: Routes for Location management functionality.
|
||||
# ABOUTME: Handles GET /locations, GET /locations/{id}, and POST /actions/location-* routes.
|
||||
|
||||
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.responses import success_toast
|
||||
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
|
||||
|
||||
# APIRouter for multi-file route organization
|
||||
@@ -33,8 +34,79 @@ def _get_location_service(db) -> LocationService:
|
||||
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":
|
||||
from_loc = payload.get("from_location_id", "")
|
||||
to_loc = payload.get("to_location_id", "")
|
||||
from_name = location_names.get(from_loc, from_loc[:8] + "..." if from_loc else "")
|
||||
to_name = location_names.get(to_loc, to_loc[:8] + "..." if to_loc else "")
|
||||
if from_name:
|
||||
items.append(payload_item("From", from_name))
|
||||
if to_name:
|
||||
items.append(payload_item("To", to_name))
|
||||
if from_loc:
|
||||
from_name = location_names.get(from_loc, from_loc[:8] + "...")
|
||||
items.append(
|
||||
payload_item_with_link(
|
||||
"From",
|
||||
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":
|
||||
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(
|
||||
entity_refs: dict[str, Any],
|
||||
location_names: dict[str, str],
|
||||
@@ -264,15 +320,27 @@ def entity_refs_section(
|
||||
if key == "animal_ids":
|
||||
continue
|
||||
|
||||
display_value = value
|
||||
# Resolve location names
|
||||
if key.endswith("_location_id") or key == "location_id":
|
||||
display_value = location_names.get(value, value[:8] + "..." if value else "")
|
||||
# Handle location references with links and tooltips
|
||||
if (key.endswith("_location_id") or key == "location_id") and isinstance(value, str):
|
||||
loc_name = location_names.get(value, value[:8] + "...")
|
||||
items.append(
|
||||
payload_item_with_link(
|
||||
key.replace("_", " ").title(),
|
||||
loc_name,
|
||||
f"/locations/{value}",
|
||||
f"ID: {value}",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle lists
|
||||
if isinstance(value, list):
|
||||
display_value = f"{len(value)} items"
|
||||
# Handle long strings
|
||||
elif isinstance(value, str) and len(value) > 20:
|
||||
display_value = value[:8] + "..."
|
||||
else:
|
||||
display_value = 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