feat: add event detail slide-over, fix toasts, and checkbox selection
Three major features implemented:
1. Event Detail Slide-Over Panel
- Click timeline events to view details in slide-over
- New /events/{event_id} route and event_detail.py template
- Type-specific payload rendering for all event types
2. Toast System Refactor
- Switch from custom addEventListener to FastHTML's add_toast()
- Replace HX-Trigger headers with session-based toasts
- Add event links in toast messages
- Replace addEventListener with hx_on_* in templates
3. Checkbox Selection for Animal Subsets
- New animal_select.py component with checkbox list
- New /api/compute-hash and /api/selection-preview endpoints
- Add subset_mode support to SelectionContext validation
- Update 5 forms: outcome, move, tag-add, tag-end, attrs
- Users can select specific animals from filtered results
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -107,6 +107,68 @@ class AnimalRepository:
|
||||
last_event_utc=row[13],
|
||||
)
|
||||
|
||||
def get_by_ids(self, animal_ids: list[str]) -> list[AnimalListItem]:
|
||||
"""Get multiple animals by ID with display info.
|
||||
|
||||
Args:
|
||||
animal_ids: List of animal IDs to look up.
|
||||
|
||||
Returns:
|
||||
List of AnimalListItem with display info. Order matches input IDs
|
||||
for animals that exist.
|
||||
"""
|
||||
if not animal_ids:
|
||||
return []
|
||||
|
||||
placeholders = ",".join("?" * len(animal_ids))
|
||||
query = f"""
|
||||
SELECT
|
||||
ar.animal_id,
|
||||
ar.species_code,
|
||||
ar.sex,
|
||||
ar.life_stage,
|
||||
ar.status,
|
||||
ar.location_id,
|
||||
l.name as location_name,
|
||||
ar.nickname,
|
||||
ar.identified,
|
||||
ar.last_event_utc,
|
||||
COALESCE(
|
||||
(SELECT json_group_array(tag)
|
||||
FROM animal_tag_intervals ati
|
||||
WHERE ati.animal_id = ar.animal_id
|
||||
AND ati.end_utc IS NULL),
|
||||
'[]'
|
||||
) as tags
|
||||
FROM animal_registry ar
|
||||
JOIN locations l ON ar.location_id = l.id
|
||||
WHERE ar.animal_id IN ({placeholders})
|
||||
"""
|
||||
|
||||
rows = self.db.execute(query, animal_ids).fetchall()
|
||||
|
||||
# Build lookup dict
|
||||
items_by_id = {}
|
||||
for row in rows:
|
||||
tags_json = row[10]
|
||||
tags = json.loads(tags_json) if tags_json else []
|
||||
items_by_id[row[0]] = AnimalListItem(
|
||||
animal_id=row[0],
|
||||
species_code=row[1],
|
||||
sex=row[2],
|
||||
life_stage=row[3],
|
||||
status=row[4],
|
||||
location_id=row[5],
|
||||
location_name=row[6],
|
||||
nickname=row[7],
|
||||
identified=bool(row[8]),
|
||||
last_event_utc=row[9],
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Return in original order, filtering out non-existent IDs
|
||||
return [items_by_id[aid] for aid in animal_ids if aid in items_by_id]
|
||||
|
||||
def list_animals(
|
||||
self,
|
||||
filter_str: str = "",
|
||||
|
||||
@@ -23,6 +23,8 @@ class SelectionContext:
|
||||
from_location_id: str | None # For move operations (included in hash)
|
||||
confirmed: bool = False # Override on mismatch
|
||||
resolver_version: str = "v1" # Fixed version string
|
||||
subset_mode: bool = False # True when user selected a subset via checkboxes
|
||||
selected_ids: list[str] | None = None # Subset of IDs selected by user
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -101,6 +103,10 @@ def validate_selection(
|
||||
confirmed=True. Returns valid=False with diff if mismatch and not
|
||||
confirmed.
|
||||
|
||||
In subset_mode, validates that selected_ids are a valid subset of
|
||||
the filter resolution. Hash is computed from selected_ids, not the
|
||||
full resolution.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
context: SelectionContext with client's filter, IDs, and hash.
|
||||
@@ -112,6 +118,11 @@ def validate_selection(
|
||||
filter_ast = parse_filter(context.filter)
|
||||
resolution = resolve_filter(db, filter_ast, context.ts_utc)
|
||||
|
||||
if context.subset_mode and context.selected_ids is not None:
|
||||
# Subset mode: validate that all selected IDs are in the resolved set
|
||||
return _validate_subset(db, context, resolution.animal_ids)
|
||||
|
||||
# Standard mode: compare full resolution hashes
|
||||
# Compute server's hash (including from_location_id if provided)
|
||||
server_hash = compute_roster_hash(
|
||||
resolution.animal_ids,
|
||||
@@ -147,3 +158,71 @@ def validate_selection(
|
||||
roster_hash=server_hash,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
|
||||
def _validate_subset(
|
||||
db: Any,
|
||||
context: SelectionContext,
|
||||
resolved_ids: list[str],
|
||||
) -> SelectionValidationResult:
|
||||
"""Validate subset selection against filter resolution.
|
||||
|
||||
Checks that all selected IDs are in the resolved set (still match filter).
|
||||
IDs that no longer match are reported in the diff.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
context: SelectionContext with subset_mode=True and selected_ids.
|
||||
resolved_ids: IDs from resolving the filter at ts_utc.
|
||||
|
||||
Returns:
|
||||
SelectionValidationResult with validation status.
|
||||
"""
|
||||
selected_ids = context.selected_ids or []
|
||||
resolved_set = set(resolved_ids)
|
||||
selected_set = set(selected_ids)
|
||||
|
||||
# Find selected IDs that no longer match the filter
|
||||
invalid_ids = selected_set - resolved_set
|
||||
|
||||
if not invalid_ids:
|
||||
# All selected IDs are valid - compute hash from selected IDs
|
||||
subset_hash = compute_roster_hash(selected_ids, context.from_location_id)
|
||||
|
||||
# Verify hash matches what client sent
|
||||
if subset_hash == context.roster_hash:
|
||||
return SelectionValidationResult(
|
||||
valid=True,
|
||||
resolved_ids=selected_ids,
|
||||
roster_hash=context.roster_hash,
|
||||
diff=None,
|
||||
)
|
||||
|
||||
# Some selected IDs are no longer valid, or hash mismatch
|
||||
# Compute diff: removed = invalid_ids, added = none
|
||||
diff = SelectionDiff(
|
||||
added=[],
|
||||
removed=sorted(invalid_ids),
|
||||
server_count=len(resolved_ids),
|
||||
client_count=len(selected_ids),
|
||||
)
|
||||
|
||||
if context.confirmed and not invalid_ids:
|
||||
# Client confirmed, and all IDs are still valid
|
||||
return SelectionValidationResult(
|
||||
valid=True,
|
||||
resolved_ids=selected_ids,
|
||||
roster_hash=context.roster_hash,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
# Invalid - return with valid selected IDs (those that still match)
|
||||
valid_selected = [sid for sid in selected_ids if sid in resolved_set]
|
||||
new_hash = compute_roster_hash(valid_selected, context.from_location_id)
|
||||
|
||||
return SelectionValidationResult(
|
||||
valid=False,
|
||||
resolved_ids=valid_selected,
|
||||
roster_hash=new_hash,
|
||||
diff=diff,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fasthtml.common import Beforeware, Meta, fast_app
|
||||
from fasthtml.common import Beforeware, Meta, fast_app, setup_toasts
|
||||
from monsterui.all import Theme
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
@@ -22,6 +22,7 @@ from animaltrack.web.middleware import (
|
||||
from animaltrack.web.routes import (
|
||||
actions_router,
|
||||
animals_router,
|
||||
api_router,
|
||||
eggs_router,
|
||||
events_router,
|
||||
feed_router,
|
||||
@@ -143,6 +144,9 @@ def create_app(
|
||||
app.state.settings = settings
|
||||
app.state.db = db
|
||||
|
||||
# Setup toast notifications with 5 second duration
|
||||
setup_toasts(app, duration=5000)
|
||||
|
||||
# Register exception handlers for auth errors
|
||||
async def authentication_error_handler(request, exc):
|
||||
return PlainTextResponse(str(exc) or "Authentication required", status_code=401)
|
||||
@@ -157,6 +161,7 @@ def create_app(
|
||||
health_router.to_app(app)
|
||||
actions_router.to_app(app)
|
||||
animals_router.to_app(app)
|
||||
api_router.to_app(app)
|
||||
eggs_router.to_app(app)
|
||||
events_router.to_app(app)
|
||||
feed_router.to_app(app)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
from animaltrack.web.routes.actions import ar as actions_router
|
||||
from animaltrack.web.routes.animals import ar as animals_router
|
||||
from animaltrack.web.routes.api import ar as api_router
|
||||
from animaltrack.web.routes.eggs import ar as eggs_router
|
||||
from animaltrack.web.routes.events import ar as events_router
|
||||
from animaltrack.web.routes.feed import ar as feed_router
|
||||
@@ -15,6 +16,7 @@ from animaltrack.web.routes.registry import ar as registry_router
|
||||
__all__ = [
|
||||
"actions_router",
|
||||
"animals_router",
|
||||
"api_router",
|
||||
"eggs_router",
|
||||
"events_router",
|
||||
"feed_router",
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import APIRouter, to_xml
|
||||
from fasthtml.common import APIRouter, add_toast, to_xml
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -119,7 +118,7 @@ def cohort_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/animal-cohort", methods=["POST"])
|
||||
async def animal_cohort(request: Request):
|
||||
async def animal_cohort(request: Request, session):
|
||||
"""POST /actions/animal-cohort - Create a new animal cohort."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -198,8 +197,16 @@ async def animal_cohort(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_cohort_error(request, locations, species_list, str(e), form)
|
||||
|
||||
# Add success toast with link to event
|
||||
animal_count = len(event.entity_refs.get("animal_ids", []))
|
||||
add_toast(
|
||||
session,
|
||||
f"Created {animal_count} {species}(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -210,19 +217,6 @@ async def animal_cohort(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
animal_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Created {animal_count} {species}(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_cohort_error(
|
||||
request: Request,
|
||||
@@ -280,7 +274,7 @@ def hatch_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/hatch-recorded", methods=["POST"])
|
||||
async def hatch_recorded(request: Request):
|
||||
async def hatch_recorded(request: Request, session):
|
||||
"""POST /actions/hatch-recorded - Record a hatch event."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -346,8 +340,16 @@ async def hatch_recorded(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_hatch_error(request, locations, species_list, str(e), form)
|
||||
|
||||
# Add success toast with link to event
|
||||
animal_count = len(event.entity_refs.get("animal_ids", []))
|
||||
add_toast(
|
||||
session,
|
||||
f"Recorded {animal_count} hatchling(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -358,19 +360,6 @@ async def hatch_recorded(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
animal_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Recorded {animal_count} hatchling(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_hatch_error(
|
||||
request: Request,
|
||||
@@ -547,6 +536,7 @@ def tag_add_index(request: Request):
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
animals = []
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
@@ -555,6 +545,9 @@ def tag_add_index(request: Request):
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
# Fetch animal details for checkbox display
|
||||
animal_repo = AnimalRepository(db)
|
||||
animals = animal_repo.get_by_ids(resolved_ids)
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
@@ -564,6 +557,7 @@ def tag_add_index(request: Request):
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
animals=animals,
|
||||
),
|
||||
title="Add Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
@@ -571,7 +565,7 @@ def tag_add_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/animal-tag-add", methods=["POST"])
|
||||
async def animal_tag_add(request: Request):
|
||||
async def animal_tag_add(request: Request, session):
|
||||
"""POST /actions/animal-tag-add - Add tag to animals."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -589,12 +583,22 @@ async def animal_tag_add(request: Request):
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Check for subset mode (user selected specific animals from checkboxes)
|
||||
subset_mode = form.get("subset_mode", "") == "true"
|
||||
selected_ids = form.getlist("selected_ids") if subset_mode else None
|
||||
|
||||
# In subset mode, use selected_ids as the animals to tag
|
||||
if subset_mode and selected_ids:
|
||||
ids_for_validation = list(selected_ids)
|
||||
else:
|
||||
ids_for_validation = list(resolved_ids)
|
||||
|
||||
# Validation: tag required
|
||||
if not tag:
|
||||
return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag")
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
if not ids_for_validation:
|
||||
return _render_tag_add_error_form(request, db, filter_str, "No animals selected")
|
||||
|
||||
# Build selection context for validation
|
||||
@@ -605,6 +609,8 @@ async def animal_tag_add(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=None,
|
||||
confirmed=confirmed,
|
||||
subset_mode=subset_mode,
|
||||
selected_ids=selected_ids,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
@@ -631,14 +637,26 @@ async def animal_tag_add(request: Request):
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs
|
||||
if confirmed:
|
||||
# Determine which IDs to use for the update
|
||||
if subset_mode and selected_ids:
|
||||
# In subset mode, use the selected IDs from checkboxes
|
||||
if confirmed:
|
||||
# When confirmed, filter selected IDs against current resolution
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
current_set = set(current_resolution.animal_ids)
|
||||
ids_to_tag = [sid for sid in selected_ids if sid in current_set]
|
||||
else:
|
||||
ids_to_tag = list(selected_ids)
|
||||
elif confirmed:
|
||||
# Standard mode with confirmation - re-resolve to get current server IDs
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
ids_to_tag = current_resolution.animal_ids
|
||||
else:
|
||||
ids_to_tag = resolved_ids
|
||||
ids_to_tag = list(resolved_ids)
|
||||
|
||||
# Check we still have animals
|
||||
if not ids_to_tag:
|
||||
@@ -667,8 +685,16 @@ async def animal_tag_add(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_tag_add_error_form(request, db, filter_str, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
actually_tagged = event.entity_refs.get("actually_tagged", [])
|
||||
add_toast(
|
||||
session,
|
||||
f"Tagged {len(actually_tagged)} animal(s) as '{tag}'. <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -679,19 +705,6 @@ async def animal_tag_add(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
actually_tagged = event.entity_refs.get("actually_tagged", [])
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Tagged {len(actually_tagged)} animal(s) as '{tag}'",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_tag_add_error_form(request, db, filter_str, error_message):
|
||||
"""Render tag add form with error message."""
|
||||
@@ -774,6 +787,7 @@ def tag_end_index(request: Request):
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
active_tags: list[str] = []
|
||||
animals = []
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
@@ -783,6 +797,9 @@ def tag_end_index(request: Request):
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
active_tags = _get_active_tags_for_animals(db, resolved_ids)
|
||||
# Fetch animal details for checkbox display
|
||||
animal_repo = AnimalRepository(db)
|
||||
animals = animal_repo.get_by_ids(resolved_ids)
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
@@ -793,6 +810,7 @@ def tag_end_index(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
active_tags=active_tags,
|
||||
animals=animals,
|
||||
),
|
||||
title="End Tag - AnimalTrack",
|
||||
active_nav=None,
|
||||
@@ -800,7 +818,7 @@ def tag_end_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/animal-tag-end", methods=["POST"])
|
||||
async def animal_tag_end(request: Request):
|
||||
async def animal_tag_end(request: Request, session):
|
||||
"""POST /actions/animal-tag-end - End tag on animals."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -818,12 +836,22 @@ async def animal_tag_end(request: Request):
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Check for subset mode (user selected specific animals from checkboxes)
|
||||
subset_mode = form.get("subset_mode", "") == "true"
|
||||
selected_ids = form.getlist("selected_ids") if subset_mode else None
|
||||
|
||||
# In subset mode, use selected_ids as the animals to untag
|
||||
if subset_mode and selected_ids:
|
||||
ids_for_validation = list(selected_ids)
|
||||
else:
|
||||
ids_for_validation = list(resolved_ids)
|
||||
|
||||
# Validation: tag required
|
||||
if not tag:
|
||||
return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end")
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
if not ids_for_validation:
|
||||
return _render_tag_end_error_form(request, db, filter_str, "No animals selected")
|
||||
|
||||
# Build selection context for validation
|
||||
@@ -834,6 +862,8 @@ async def animal_tag_end(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=None,
|
||||
confirmed=confirmed,
|
||||
subset_mode=subset_mode,
|
||||
selected_ids=selected_ids,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
@@ -860,14 +890,26 @@ async def animal_tag_end(request: Request):
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs
|
||||
if confirmed:
|
||||
# Determine which IDs to use for the update
|
||||
if subset_mode and selected_ids:
|
||||
# In subset mode, use the selected IDs from checkboxes
|
||||
if confirmed:
|
||||
# When confirmed, filter selected IDs against current resolution
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
current_set = set(current_resolution.animal_ids)
|
||||
ids_to_untag = [sid for sid in selected_ids if sid in current_set]
|
||||
else:
|
||||
ids_to_untag = list(selected_ids)
|
||||
elif confirmed:
|
||||
# Standard mode with confirmation - re-resolve to get current server IDs
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
ids_to_untag = current_resolution.animal_ids
|
||||
else:
|
||||
ids_to_untag = resolved_ids
|
||||
ids_to_untag = list(resolved_ids)
|
||||
|
||||
# Check we still have animals
|
||||
if not ids_to_untag:
|
||||
@@ -896,8 +938,16 @@ async def animal_tag_end(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_tag_end_error_form(request, db, filter_str, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
actually_ended = event.entity_refs.get("actually_ended", [])
|
||||
add_toast(
|
||||
session,
|
||||
f"Ended tag '{tag}' on {len(actually_ended)} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -908,19 +958,6 @@ async def animal_tag_end(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
actually_ended = event.entity_refs.get("actually_ended", [])
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Ended tag '{tag}' on {len(actually_ended)} animal(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_tag_end_error_form(request, db, filter_str, error_message):
|
||||
"""Render tag end form with error message."""
|
||||
@@ -977,6 +1014,7 @@ def attrs_index(request: Request):
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
animals = []
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
@@ -985,6 +1023,9 @@ def attrs_index(request: Request):
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
# Fetch animal details for checkbox display
|
||||
animal_repo = AnimalRepository(db)
|
||||
animals = animal_repo.get_by_ids(resolved_ids)
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
@@ -994,6 +1035,7 @@ def attrs_index(request: Request):
|
||||
roster_hash=roster_hash,
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
animals=animals,
|
||||
),
|
||||
title="Update Attributes - AnimalTrack",
|
||||
active_nav=None,
|
||||
@@ -1001,7 +1043,7 @@ def attrs_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/animal-attrs", methods=["POST"])
|
||||
async def animal_attrs(request: Request):
|
||||
async def animal_attrs(request: Request, session):
|
||||
"""POST /actions/animal-attrs - Update attributes on animals."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -1021,6 +1063,16 @@ async def animal_attrs(request: Request):
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Check for subset mode (user selected specific animals from checkboxes)
|
||||
subset_mode = form.get("subset_mode", "") == "true"
|
||||
selected_ids = form.getlist("selected_ids") if subset_mode else None
|
||||
|
||||
# In subset mode, use selected_ids as the animals to update
|
||||
if subset_mode and selected_ids:
|
||||
ids_for_validation = list(selected_ids)
|
||||
else:
|
||||
ids_for_validation = list(resolved_ids)
|
||||
|
||||
# Validation: at least one attribute required
|
||||
if not sex and not life_stage and not repro_status:
|
||||
return _render_attrs_error_form(
|
||||
@@ -1028,7 +1080,7 @@ async def animal_attrs(request: Request):
|
||||
)
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
if not ids_for_validation:
|
||||
return _render_attrs_error_form(request, db, filter_str, "No animals selected")
|
||||
|
||||
# Build selection context for validation
|
||||
@@ -1039,6 +1091,8 @@ async def animal_attrs(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=None,
|
||||
confirmed=confirmed,
|
||||
subset_mode=subset_mode,
|
||||
selected_ids=selected_ids,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
@@ -1067,14 +1121,26 @@ async def animal_attrs(request: Request):
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs
|
||||
if confirmed:
|
||||
# Determine which IDs to use for the update
|
||||
if subset_mode and selected_ids:
|
||||
# In subset mode, use the selected IDs from checkboxes
|
||||
if confirmed:
|
||||
# When confirmed, filter selected IDs against current resolution
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
current_set = set(current_resolution.animal_ids)
|
||||
ids_to_update = [sid for sid in selected_ids if sid in current_set]
|
||||
else:
|
||||
ids_to_update = list(selected_ids)
|
||||
elif confirmed:
|
||||
# Standard mode with confirmation - re-resolve to get current server IDs
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
ids_to_update = current_resolution.animal_ids
|
||||
else:
|
||||
ids_to_update = resolved_ids
|
||||
ids_to_update = list(resolved_ids)
|
||||
|
||||
# Check we still have animals
|
||||
if not ids_to_update:
|
||||
@@ -1108,8 +1174,16 @@ async def animal_attrs(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_attrs_error_form(request, db, filter_str, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
updated_count = len(event.entity_refs.get("animal_ids", []))
|
||||
add_toast(
|
||||
session,
|
||||
f"Updated attributes on {updated_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -1120,19 +1194,6 @@ async def animal_attrs(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
updated_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Updated attributes on {updated_count} animal(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_attrs_error_form(request, db, filter_str, error_message):
|
||||
"""Render attributes form with error message."""
|
||||
@@ -1186,6 +1247,7 @@ def outcome_index(request: Request):
|
||||
ts_utc = int(time.time() * 1000)
|
||||
resolved_ids: list[str] = []
|
||||
roster_hash = ""
|
||||
animals = []
|
||||
|
||||
if filter_str:
|
||||
filter_ast = parse_filter(filter_str)
|
||||
@@ -1194,6 +1256,9 @@ def outcome_index(request: Request):
|
||||
|
||||
if resolved_ids:
|
||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||
# Fetch animal details for checkbox display
|
||||
animal_repo = AnimalRepository(db)
|
||||
animals = animal_repo.get_by_ids(resolved_ids)
|
||||
|
||||
# Get active products for yield items dropdown
|
||||
product_repo = ProductRepository(db)
|
||||
@@ -1208,6 +1273,7 @@ def outcome_index(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
resolved_count=len(resolved_ids),
|
||||
products=products,
|
||||
animals=animals,
|
||||
),
|
||||
title="Record Outcome - AnimalTrack",
|
||||
active_nav=None,
|
||||
@@ -1215,7 +1281,7 @@ def outcome_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/animal-outcome", methods=["POST"])
|
||||
async def animal_outcome(request: Request):
|
||||
async def animal_outcome(request: Request, session):
|
||||
"""POST /actions/animal-outcome - Record outcome for animals."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -1256,6 +1322,16 @@ async def animal_outcome(request: Request):
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Check for subset mode (user selected specific animals from checkboxes)
|
||||
subset_mode = form.get("subset_mode", "") == "true"
|
||||
selected_ids = form.getlist("selected_ids") if subset_mode else None
|
||||
|
||||
# In subset mode, use selected_ids as the animals to update
|
||||
if subset_mode and selected_ids:
|
||||
ids_for_validation = list(selected_ids)
|
||||
else:
|
||||
ids_for_validation = list(resolved_ids)
|
||||
|
||||
# Validation: outcome required
|
||||
if not outcome_str:
|
||||
return _render_outcome_error_form(request, db, filter_str, "Please select an outcome")
|
||||
@@ -1269,7 +1345,7 @@ async def animal_outcome(request: Request):
|
||||
)
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
if not ids_for_validation:
|
||||
return _render_outcome_error_form(request, db, filter_str, "No animals selected")
|
||||
|
||||
# Build selection context for validation
|
||||
@@ -1280,6 +1356,8 @@ async def animal_outcome(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=None,
|
||||
confirmed=confirmed,
|
||||
subset_mode=subset_mode,
|
||||
selected_ids=selected_ids,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
@@ -1311,14 +1389,26 @@ async def animal_outcome(request: Request):
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs
|
||||
if confirmed:
|
||||
# Determine which IDs to use for the update
|
||||
if subset_mode and selected_ids:
|
||||
# In subset mode, use the selected IDs from checkboxes
|
||||
if confirmed:
|
||||
# When confirmed, filter selected IDs against current resolution
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
current_set = set(current_resolution.animal_ids)
|
||||
ids_to_update = [sid for sid in selected_ids if sid in current_set]
|
||||
else:
|
||||
ids_to_update = list(selected_ids)
|
||||
elif confirmed:
|
||||
# Standard mode with confirmation - re-resolve to get current server IDs
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
ids_to_update = current_resolution.animal_ids
|
||||
else:
|
||||
ids_to_update = resolved_ids
|
||||
ids_to_update = list(resolved_ids)
|
||||
|
||||
# Check we still have animals
|
||||
if not ids_to_update:
|
||||
@@ -1366,11 +1456,19 @@ async def animal_outcome(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_outcome_error_form(request, db, filter_str, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
outcome_count = len(event.entity_refs.get("animal_ids", []))
|
||||
add_toast(
|
||||
session,
|
||||
f"Recorded {outcome_str} for {outcome_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
product_repo = ProductRepository(db)
|
||||
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
|
||||
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -1388,19 +1486,6 @@ async def animal_outcome(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
outcome_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Recorded {outcome_str} for {outcome_count} animal(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_outcome_error_form(request, db, filter_str, error_message):
|
||||
"""Render outcome form with error message."""
|
||||
@@ -1485,7 +1570,7 @@ async def status_correct_index(req: Request):
|
||||
|
||||
@ar("/actions/animal-status-correct", methods=["POST"])
|
||||
@require_role(UserRole.ADMIN)
|
||||
async def animal_status_correct(req: Request):
|
||||
async def animal_status_correct(req: Request, session):
|
||||
"""POST /actions/animal-status-correct - Correct status of animals (admin-only)."""
|
||||
db = req.app.state.db
|
||||
form = await req.form()
|
||||
@@ -1598,8 +1683,16 @@ async def animal_status_correct(req: Request):
|
||||
except ValidationError as e:
|
||||
return _render_status_correct_error_form(req, db, filter_str, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
corrected_count = len(event.entity_refs.get("animal_ids", []))
|
||||
add_toast(
|
||||
session,
|
||||
f"Corrected status to {new_status_str} for {corrected_count} animal(s). <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
req,
|
||||
@@ -1616,19 +1709,6 @@ async def animal_status_correct(req: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
corrected_count = len(event.entity_refs.get("animal_ids", []))
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Corrected status to {new_status_str} for {corrected_count} animal(s)",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_status_correct_error_form(request, db, filter_str, error_message):
|
||||
"""Render status correct form with error message."""
|
||||
|
||||
84
src/animaltrack/web/routes/api.py
Normal file
84
src/animaltrack/web/routes/api.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# ABOUTME: API routes for HTMX partial updates.
|
||||
# ABOUTME: Provides endpoints for selection preview and hash computation.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from fasthtml.common import APIRouter
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from animaltrack.repositories.animals import AnimalRepository
|
||||
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||
|
||||
# APIRouter for multi-file route organization
|
||||
ar = APIRouter()
|
||||
|
||||
|
||||
@ar("/api/compute-hash", methods=["POST"])
|
||||
async def compute_hash(request: Request):
|
||||
"""POST /api/compute-hash - Compute roster hash for selected IDs.
|
||||
|
||||
Expects JSON body with:
|
||||
- selected_ids: list of animal IDs
|
||||
- from_location_id: optional location ID
|
||||
|
||||
Returns JSON with:
|
||||
- roster_hash: computed hash string
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
selected_ids = body.get("selected_ids", [])
|
||||
from_location_id = body.get("from_location_id") or None
|
||||
|
||||
roster_hash = compute_roster_hash(selected_ids, from_location_id)
|
||||
|
||||
return JSONResponse({"roster_hash": roster_hash})
|
||||
|
||||
|
||||
@ar("/api/selection-preview")
|
||||
def selection_preview(request: Request):
|
||||
"""GET /api/selection-preview - Get animal checkbox list for filter.
|
||||
|
||||
Query params:
|
||||
- filter: DSL filter string
|
||||
- selected_ids: optional comma-separated IDs to pre-select
|
||||
|
||||
Returns HTML partial with checkbox list.
|
||||
"""
|
||||
db = request.app.state.db
|
||||
filter_str = request.query_params.get("filter", "")
|
||||
selected_param = request.query_params.get("selected_ids", "")
|
||||
|
||||
# Parse pre-selected IDs if provided
|
||||
selected_ids = None
|
||||
if selected_param:
|
||||
selected_ids = [s.strip() for s in selected_param.split(",") if s.strip()]
|
||||
|
||||
# Resolve filter to get animals
|
||||
ts_utc = int(time.time() * 1000)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
resolution = resolve_filter(db, filter_ast, ts_utc)
|
||||
|
||||
if not resolution.animal_ids:
|
||||
return HTMLResponse(
|
||||
content="<p class='text-stone-500 text-sm'>No animals match this filter</p>"
|
||||
)
|
||||
|
||||
# Get animal details
|
||||
animal_repo = AnimalRepository(db)
|
||||
animals = animal_repo.get_by_ids(resolution.animal_ids)
|
||||
|
||||
# If no pre-selection, default to all selected
|
||||
if selected_ids is None:
|
||||
selected_ids = [a.animal_id for a in animals]
|
||||
|
||||
# Render checkbox list
|
||||
from fasthtml.common import to_xml
|
||||
|
||||
return HTMLResponse(content=to_xml(animal_checkbox_list(animals, selected_ids)))
|
||||
@@ -3,11 +3,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import APIRouter, to_xml
|
||||
from fasthtml.common import APIRouter, add_toast, to_xml
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -112,7 +111,7 @@ def egg_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/product-collected", methods=["POST"])
|
||||
async def product_collected(request: Request):
|
||||
async def product_collected(request: Request, session):
|
||||
"""POST /actions/product-collected - Record egg collection."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -181,7 +180,7 @@ async def product_collected(request: Request):
|
||||
|
||||
# Collect product
|
||||
try:
|
||||
product_service.collect_product(
|
||||
event = product_service.collect_product(
|
||||
payload=payload,
|
||||
ts_utc=ts_utc,
|
||||
actor=actor,
|
||||
@@ -202,8 +201,15 @@ async def product_collected(request: Request):
|
||||
)
|
||||
)
|
||||
|
||||
# Add success toast with link to event
|
||||
add_toast(
|
||||
session,
|
||||
f"Recorded {quantity} eggs. <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render form with location sticking, qty cleared
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -221,16 +227,9 @@ async def product_collected(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"showToast": {"message": f"Recorded {quantity} eggs", "type": "success"}}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@ar("/actions/product-sold", methods=["POST"])
|
||||
async def product_sold(request: Request):
|
||||
async def product_sold(request: Request, session):
|
||||
"""POST /actions/product-sold - Record product sale (from Eggs page Sell tab)."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -303,7 +302,7 @@ async def product_sold(request: Request):
|
||||
|
||||
# Sell product
|
||||
try:
|
||||
product_service.sell_product(
|
||||
event = product_service.sell_product(
|
||||
payload=payload,
|
||||
ts_utc=ts_utc,
|
||||
actor=actor,
|
||||
@@ -313,8 +312,15 @@ async def product_sold(request: Request):
|
||||
except ValidationError as e:
|
||||
return _render_sell_error(request, locations, products, product_code, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
add_toast(
|
||||
session,
|
||||
f"Recorded sale of {quantity} {product_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render form with product sticking
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -332,13 +338,6 @@ async def product_sold(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{"showToast": {"message": f"Recorded sale of {quantity} {product_code}", "type": "success"}}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_harvest_error(request, locations, products, selected_location_id, error_message):
|
||||
"""Render harvest form with error message.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ABOUTME: Routes for event log functionality.
|
||||
# ABOUTME: Handles GET /event-log for viewing location event history.
|
||||
# ABOUTME: Routes for event log and event detail functionality.
|
||||
# ABOUTME: Handles GET /event-log for location event history and GET /events/{id} for event details.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,9 +10,11 @@ from fasthtml.common import APIRouter, to_xml
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from animaltrack.events.store import EventStore
|
||||
from animaltrack.repositories.locations import LocationRepository
|
||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||
from animaltrack.web.templates import render_page
|
||||
from animaltrack.web.templates.event_detail import event_detail_panel
|
||||
from animaltrack.web.templates.events import event_log_list, event_log_panel
|
||||
|
||||
# APIRouter for multi-file route organization
|
||||
@@ -105,3 +107,72 @@ def event_log_index(request: Request):
|
||||
title="Event Log - AnimalTrack",
|
||||
active_nav="event_log",
|
||||
)
|
||||
|
||||
|
||||
def get_event_animals(db: Any, event_id: str) -> list[dict[str, Any]]:
|
||||
"""Get animals affected by an event with display info.
|
||||
|
||||
Args:
|
||||
db: Database connection.
|
||||
event_id: Event ID to look up animals for.
|
||||
|
||||
Returns:
|
||||
List of animal dicts with id, nickname, species_name.
|
||||
"""
|
||||
rows = db.execute(
|
||||
"""
|
||||
SELECT ar.id, ar.nickname, s.name as species_name
|
||||
FROM event_animals ea
|
||||
JOIN animal_registry ar ON ar.id = ea.animal_id
|
||||
JOIN species s ON s.code = ar.species_code
|
||||
WHERE ea.event_id = ?
|
||||
ORDER BY ar.nickname NULLS LAST, ar.id
|
||||
""",
|
||||
(event_id,),
|
||||
).fetchall()
|
||||
|
||||
return [{"id": row[0], "nickname": row[1], "species_name": row[2]} for row in rows]
|
||||
|
||||
|
||||
@ar("/events/{event_id}")
|
||||
def event_detail(request: Request, event_id: str):
|
||||
"""GET /events/{event_id} - Event detail panel for slide-over."""
|
||||
db = request.app.state.db
|
||||
|
||||
# Get event from store
|
||||
event_store = EventStore(db)
|
||||
event = event_store.get_event(event_id)
|
||||
|
||||
if event is None:
|
||||
return HTMLResponse(
|
||||
content="<div class='p-4 text-red-400'>Event not found</div>",
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
# Check if tombstoned
|
||||
is_tombstoned = event_store.is_tombstoned(event_id)
|
||||
|
||||
# Get affected animals
|
||||
affected_animals = get_event_animals(db, event_id)
|
||||
|
||||
# Get location names if entity_refs has location IDs
|
||||
location_names = {}
|
||||
location_ids = []
|
||||
if "location_id" in event.entity_refs:
|
||||
location_ids.append(event.entity_refs["location_id"])
|
||||
if "from_location_id" in event.entity_refs:
|
||||
location_ids.append(event.entity_refs["from_location_id"])
|
||||
if "to_location_id" in event.entity_refs:
|
||||
location_ids.append(event.entity_refs["to_location_id"])
|
||||
|
||||
if location_ids:
|
||||
loc_repo = LocationRepository(db)
|
||||
for loc_id in location_ids:
|
||||
loc = loc_repo.get(loc_id)
|
||||
if loc:
|
||||
location_names[loc_id] = loc.name
|
||||
|
||||
# Return slide-over panel HTML
|
||||
return HTMLResponse(
|
||||
content=to_xml(event_detail_panel(event, affected_animals, is_tombstoned, location_names)),
|
||||
)
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import APIRouter
|
||||
from fasthtml.common import APIRouter, add_toast
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -109,7 +108,7 @@ def feed_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/feed-given", methods=["POST"])
|
||||
async def feed_given(request: Request):
|
||||
async def feed_given(request: Request, session):
|
||||
"""POST /actions/feed-given - Record feed given."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -202,7 +201,7 @@ async def feed_given(request: Request):
|
||||
|
||||
# Give feed
|
||||
try:
|
||||
feed_service.give_feed(
|
||||
event = feed_service.give_feed(
|
||||
payload=payload,
|
||||
ts_utc=ts_utc,
|
||||
actor=actor,
|
||||
@@ -238,8 +237,15 @@ async def feed_given(request: Request):
|
||||
)
|
||||
)
|
||||
|
||||
# Add success toast with link to event
|
||||
add_toast(
|
||||
session,
|
||||
f"Recorded {amount_kg}kg {feed_type_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render form with location/type sticking, amount reset
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=str(
|
||||
render_page(
|
||||
request,
|
||||
@@ -260,21 +266,9 @@ async def feed_given(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Recorded {amount_kg}kg {feed_type_code}",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@ar("/actions/feed-purchased", methods=["POST"])
|
||||
async def feed_purchased(request: Request):
|
||||
async def feed_purchased(request: Request, session):
|
||||
"""POST /actions/feed-purchased - Record feed purchase."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -384,7 +378,7 @@ async def feed_purchased(request: Request):
|
||||
|
||||
# Purchase feed
|
||||
try:
|
||||
feed_service.purchase_feed(
|
||||
event = feed_service.purchase_feed(
|
||||
payload=payload,
|
||||
ts_utc=ts_utc,
|
||||
actor=actor,
|
||||
@@ -402,8 +396,15 @@ async def feed_purchased(request: Request):
|
||||
# Calculate total for toast
|
||||
total_kg = bag_size_kg * bags_count
|
||||
|
||||
# Add success toast with link to event
|
||||
add_toast(
|
||||
session,
|
||||
f"Purchased {total_kg}kg {feed_type_code}. <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render form with fields cleared
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=str(
|
||||
render_page(
|
||||
request,
|
||||
@@ -420,18 +421,6 @@ async def feed_purchased(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Purchased {total_kg}kg {feed_type_code}",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_give_error(
|
||||
request,
|
||||
|
||||
@@ -3,11 +3,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import APIRouter, to_xml
|
||||
from fasthtml.common import APIRouter, add_toast, to_xml
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
@@ -17,6 +16,7 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
|
||||
from animaltrack.projections.animal_registry import AnimalRegistryProjection
|
||||
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||
from animaltrack.projections.intervals import IntervalProjection
|
||||
from animaltrack.repositories.animals import AnimalRepository
|
||||
from animaltrack.repositories.locations import LocationRepository
|
||||
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||
@@ -100,6 +100,7 @@ def move_index(request: Request):
|
||||
roster_hash = ""
|
||||
from_location_id = None
|
||||
from_location_name = None
|
||||
animals = []
|
||||
|
||||
if filter_str or not request.query_params:
|
||||
# If no filter, default to empty (show all alive animals)
|
||||
@@ -110,6 +111,9 @@ def move_index(request: Request):
|
||||
if resolved_ids:
|
||||
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
|
||||
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
|
||||
# Fetch animal details for checkbox display
|
||||
animal_repo = AnimalRepository(db)
|
||||
animals = animal_repo.get_by_ids(resolved_ids)
|
||||
|
||||
return render_page(
|
||||
request,
|
||||
@@ -123,6 +127,7 @@ def move_index(request: Request):
|
||||
resolved_count=len(resolved_ids),
|
||||
from_location_name=from_location_name,
|
||||
action=animal_move,
|
||||
animals=animals,
|
||||
),
|
||||
title="Move - AnimalTrack",
|
||||
active_nav="move",
|
||||
@@ -130,7 +135,7 @@ def move_index(request: Request):
|
||||
|
||||
|
||||
@ar("/actions/animal-move", methods=["POST"])
|
||||
async def animal_move(request: Request):
|
||||
async def animal_move(request: Request, session):
|
||||
"""POST /actions/animal-move - Move animals to new location."""
|
||||
db = request.app.state.db
|
||||
form = await request.form()
|
||||
@@ -149,6 +154,16 @@ async def animal_move(request: Request):
|
||||
# resolved_ids can be multiple values
|
||||
resolved_ids = form.getlist("resolved_ids")
|
||||
|
||||
# Check for subset mode (user selected specific animals from checkboxes)
|
||||
subset_mode = form.get("subset_mode", "") == "true"
|
||||
selected_ids = form.getlist("selected_ids") if subset_mode else None
|
||||
|
||||
# In subset mode, use selected_ids as the animals to move
|
||||
if subset_mode and selected_ids:
|
||||
ids_for_validation = list(selected_ids)
|
||||
else:
|
||||
ids_for_validation = list(resolved_ids)
|
||||
|
||||
# Get locations for potential re-render
|
||||
locations = LocationRepository(db).list_active()
|
||||
|
||||
@@ -157,7 +172,7 @@ async def animal_move(request: Request):
|
||||
return _render_error_form(request, db, locations, filter_str, "Please select a destination")
|
||||
|
||||
# Validation: must have animals
|
||||
if not resolved_ids:
|
||||
if not ids_for_validation:
|
||||
return _render_error_form(request, db, locations, filter_str, "No animals selected to move")
|
||||
|
||||
# Validation: destination must be different from source
|
||||
@@ -186,6 +201,8 @@ async def animal_move(request: Request):
|
||||
ts_utc=ts_utc,
|
||||
from_location_id=from_location_id,
|
||||
confirmed=confirmed,
|
||||
subset_mode=subset_mode,
|
||||
selected_ids=selected_ids,
|
||||
)
|
||||
|
||||
# Validate selection (check for concurrent changes)
|
||||
@@ -215,11 +232,22 @@ async def animal_move(request: Request):
|
||||
status_code=409,
|
||||
)
|
||||
|
||||
# When confirmed, re-resolve to get current server IDs (per spec: "server re-resolves")
|
||||
if confirmed:
|
||||
# Re-resolve the filter at current timestamp to get animals still matching
|
||||
# Use max of current time and form's ts_utc to ensure we resolve at least
|
||||
# as late as the submission - important when moves happened after client's resolution
|
||||
# Determine which IDs to use for the move
|
||||
if subset_mode and selected_ids:
|
||||
# In subset mode, use the selected IDs from checkboxes
|
||||
if confirmed:
|
||||
# When confirmed, filter selected IDs against current resolution
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
current_set = set(current_resolution.animal_ids)
|
||||
ids_to_move = [sid for sid in selected_ids if sid in current_set]
|
||||
# Update from_location_id based on filtered selected IDs
|
||||
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
|
||||
else:
|
||||
ids_to_move = list(selected_ids)
|
||||
elif confirmed:
|
||||
# Standard mode with confirmation - re-resolve to get current server IDs
|
||||
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||
filter_ast = parse_filter(filter_str)
|
||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||
@@ -227,7 +255,7 @@ async def animal_move(request: Request):
|
||||
# Update from_location_id based on current resolution
|
||||
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
|
||||
else:
|
||||
ids_to_move = resolved_ids
|
||||
ids_to_move = list(resolved_ids)
|
||||
|
||||
# Check we still have animals to move after validation
|
||||
if not ids_to_move:
|
||||
@@ -257,14 +285,21 @@ async def animal_move(request: Request):
|
||||
|
||||
# Move animals
|
||||
try:
|
||||
animal_service.move_animals(
|
||||
event = animal_service.move_animals(
|
||||
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
|
||||
)
|
||||
except ValidationError as e:
|
||||
return _render_error_form(request, db, locations, filter_str, str(e))
|
||||
|
||||
# Add success toast with link to event
|
||||
add_toast(
|
||||
session,
|
||||
f"Moved {len(ids_to_move)} animals to {dest_location.name}. <a href='/events/{event.id}' class='underline'>View event →</a>",
|
||||
"success",
|
||||
)
|
||||
|
||||
# Success: re-render fresh form (nothing sticks per spec)
|
||||
response = HTMLResponse(
|
||||
return HTMLResponse(
|
||||
content=to_xml(
|
||||
render_page(
|
||||
request,
|
||||
@@ -278,18 +313,6 @@ async def animal_move(request: Request):
|
||||
),
|
||||
)
|
||||
|
||||
# Add toast trigger header
|
||||
response.headers["HX-Trigger"] = json.dumps(
|
||||
{
|
||||
"showToast": {
|
||||
"message": f"Moved {len(ids_to_move)} animals to {dest_location.name}",
|
||||
"type": "success",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _render_error_form(request, db, locations, filter_str, error_message):
|
||||
"""Render form with error message.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Script, Span
|
||||
from fasthtml.common import H2, H3, Div, Form, Hidden, Input, Option, P, Span
|
||||
from monsterui.all import (
|
||||
Alert,
|
||||
AlertT,
|
||||
@@ -45,7 +45,6 @@ def event_datetime_field(
|
||||
Returns:
|
||||
Div containing the datetime picker with toggle functionality.
|
||||
"""
|
||||
toggle_id = f"{field_id}_toggle"
|
||||
picker_id = f"{field_id}_picker"
|
||||
input_id = f"{field_id}_input"
|
||||
|
||||
@@ -54,38 +53,31 @@ def event_datetime_field(
|
||||
picker_style = "display: block;" if has_initial else "display: none;"
|
||||
toggle_text = "Use current time" if has_initial else "Set custom date"
|
||||
|
||||
# JavaScript for toggle and conversion
|
||||
script = f"""
|
||||
(function() {{
|
||||
var toggle = document.getElementById('{toggle_id}');
|
||||
# Inline JavaScript for toggle click handler
|
||||
toggle_onclick = f"""
|
||||
var picker = document.getElementById('{picker_id}');
|
||||
var input = document.getElementById('{input_id}');
|
||||
var tsField = document.querySelector('input[name="ts_utc"]');
|
||||
if (picker.style.display === 'none') {{
|
||||
picker.style.display = 'block';
|
||||
this.textContent = 'Use current time';
|
||||
}} else {{
|
||||
picker.style.display = 'none';
|
||||
this.textContent = 'Set custom date';
|
||||
input.value = '';
|
||||
if (tsField) tsField.value = '0';
|
||||
}}
|
||||
"""
|
||||
|
||||
if (!toggle || !picker || !input) return;
|
||||
|
||||
toggle.addEventListener('click', function(e) {{
|
||||
e.preventDefault();
|
||||
if (picker.style.display === 'none') {{
|
||||
picker.style.display = 'block';
|
||||
toggle.textContent = 'Use current time';
|
||||
}} else {{
|
||||
picker.style.display = 'none';
|
||||
toggle.textContent = 'Set custom date';
|
||||
input.value = '';
|
||||
if (tsField) tsField.value = '0';
|
||||
}}
|
||||
}});
|
||||
|
||||
input.addEventListener('change', function() {{
|
||||
if (tsField && input.value) {{
|
||||
var date = new Date(input.value);
|
||||
tsField.value = date.getTime().toString();
|
||||
}} else if (tsField) {{
|
||||
tsField.value = '0';
|
||||
}}
|
||||
}});
|
||||
}})();
|
||||
# Inline JavaScript for input change handler
|
||||
input_onchange = """
|
||||
var tsField = document.querySelector('input[name="ts_utc"]');
|
||||
if (tsField && this.value) {
|
||||
var date = new Date(this.value);
|
||||
tsField.value = date.getTime().toString();
|
||||
} else if (tsField) {
|
||||
tsField.value = '0';
|
||||
}
|
||||
"""
|
||||
|
||||
return Div(
|
||||
@@ -96,8 +88,8 @@ def event_datetime_field(
|
||||
" - ",
|
||||
Span(
|
||||
toggle_text,
|
||||
id=toggle_id,
|
||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
||||
hx_on_click=toggle_onclick,
|
||||
),
|
||||
cls="text-sm",
|
||||
),
|
||||
@@ -108,6 +100,7 @@ def event_datetime_field(
|
||||
type="datetime-local",
|
||||
value=initial_value,
|
||||
cls="uk-input w-full mt-2",
|
||||
hx_on_change=input_onchange,
|
||||
),
|
||||
P(
|
||||
"Select date/time for this event (leave empty for current time)",
|
||||
@@ -119,7 +112,6 @@ def event_datetime_field(
|
||||
cls="mt-1",
|
||||
),
|
||||
Hidden(name="ts_utc", value=initial_ts),
|
||||
Script(script),
|
||||
cls="space-y-1",
|
||||
)
|
||||
|
||||
@@ -544,6 +536,7 @@ def tag_add_form(
|
||||
resolved_count: int = 0,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-tag-add",
|
||||
animals: list | None = None,
|
||||
) -> Form:
|
||||
"""Create the Add Tag form.
|
||||
|
||||
@@ -555,22 +548,36 @@ def tag_add_form(
|
||||
resolved_count: Number of resolved animals.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
animals: List of AnimalListItem for checkbox selection (optional).
|
||||
|
||||
Returns:
|
||||
Form component for adding tags to animals.
|
||||
"""
|
||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
if animals is None:
|
||||
animals = []
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
selection_preview = Div(
|
||||
# Selection component - show checkboxes if animals provided and > 1
|
||||
selection_component = None
|
||||
subset_mode = False
|
||||
if animals and len(animals) > 1:
|
||||
# Show checkbox list for subset selection
|
||||
selection_component = Div(
|
||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||
animal_checkbox_list(animals, resolved_ids),
|
||||
cls="mb-4",
|
||||
)
|
||||
subset_mode = True
|
||||
elif resolved_count > 0:
|
||||
selection_component = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
" animals selected",
|
||||
@@ -579,7 +586,7 @@ def tag_add_form(
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
selection_component = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
@@ -601,8 +608,8 @@ def tag_add_form(
|
||||
value=filter_str,
|
||||
placeholder='e.g., location:"Strip 1" species:duck',
|
||||
),
|
||||
# Selection preview
|
||||
selection_preview,
|
||||
# Selection component (checkboxes or simple count)
|
||||
selection_component,
|
||||
# Tag input
|
||||
LabelInput(
|
||||
"Tag",
|
||||
@@ -623,6 +630,7 @@ 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),
|
||||
@@ -727,6 +735,7 @@ def tag_end_form(
|
||||
active_tags: list[str] | None = None,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-tag-end",
|
||||
animals: list | None = None,
|
||||
) -> Form:
|
||||
"""Create the End Tag form.
|
||||
|
||||
@@ -739,24 +748,38 @@ def tag_end_form(
|
||||
active_tags: List of tags active on selected animals.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
animals: List of AnimalListItem for checkbox selection (optional).
|
||||
|
||||
Returns:
|
||||
Form component for ending tags on animals.
|
||||
"""
|
||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
if active_tags is None:
|
||||
active_tags = []
|
||||
if animals is None:
|
||||
animals = []
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
selection_preview = Div(
|
||||
# Selection component - show checkboxes if animals provided and > 1
|
||||
selection_component = None
|
||||
subset_mode = False
|
||||
if animals and len(animals) > 1:
|
||||
# Show checkbox list for subset selection
|
||||
selection_component = Div(
|
||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||
animal_checkbox_list(animals, resolved_ids),
|
||||
cls="mb-4",
|
||||
)
|
||||
subset_mode = True
|
||||
elif resolved_count > 0:
|
||||
selection_component = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
" animals selected",
|
||||
@@ -765,7 +788,7 @@ def tag_end_form(
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
selection_component = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
@@ -792,8 +815,8 @@ def tag_end_form(
|
||||
value=filter_str,
|
||||
placeholder="e.g., tag:layer-birds species:duck",
|
||||
),
|
||||
# Selection preview
|
||||
selection_preview,
|
||||
# Selection component (checkboxes or simple count)
|
||||
selection_component,
|
||||
# Tag dropdown
|
||||
LabelSelect(
|
||||
*tag_options,
|
||||
@@ -819,6 +842,7 @@ 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),
|
||||
@@ -922,6 +946,7 @@ def attrs_form(
|
||||
resolved_count: int = 0,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-attrs",
|
||||
animals: list | None = None,
|
||||
) -> Form:
|
||||
"""Create the Update Attributes form.
|
||||
|
||||
@@ -933,22 +958,36 @@ def attrs_form(
|
||||
resolved_count: Number of resolved animals.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
animals: List of AnimalListItem for checkbox selection (optional).
|
||||
|
||||
Returns:
|
||||
Form component for updating animal attributes.
|
||||
"""
|
||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
if animals is None:
|
||||
animals = []
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
selection_preview = Div(
|
||||
# Selection component - show checkboxes if animals provided and > 1
|
||||
selection_component = None
|
||||
subset_mode = False
|
||||
if animals and len(animals) > 1:
|
||||
# Show checkbox list for subset selection
|
||||
selection_component = Div(
|
||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||
animal_checkbox_list(animals, resolved_ids),
|
||||
cls="mb-4",
|
||||
)
|
||||
subset_mode = True
|
||||
elif resolved_count > 0:
|
||||
selection_component = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
" animals selected",
|
||||
@@ -957,7 +996,7 @@ def attrs_form(
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
selection_component = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
@@ -1005,8 +1044,8 @@ def attrs_form(
|
||||
value=filter_str,
|
||||
placeholder="e.g., species:duck life_stage:juvenile",
|
||||
),
|
||||
# Selection preview
|
||||
selection_preview,
|
||||
# Selection component (checkboxes or simple count)
|
||||
selection_component,
|
||||
# Attribute dropdowns
|
||||
LabelSelect(
|
||||
*sex_options,
|
||||
@@ -1039,6 +1078,7 @@ 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),
|
||||
@@ -1149,6 +1189,7 @@ def outcome_form(
|
||||
products: list[tuple[str, str]] | None = None,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-outcome",
|
||||
animals: list | None = None,
|
||||
) -> Form:
|
||||
"""Create the Record Outcome form.
|
||||
|
||||
@@ -1161,24 +1202,39 @@ def outcome_form(
|
||||
products: List of (code, name) tuples for product dropdown.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
animals: List of AnimalListItem for checkbox selection (optional).
|
||||
|
||||
Returns:
|
||||
Form component for recording animal outcomes.
|
||||
"""
|
||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
if products is None:
|
||||
products = []
|
||||
if animals is None:
|
||||
animals = []
|
||||
|
||||
# Error display component
|
||||
error_component = None
|
||||
if error:
|
||||
error_component = Alert(error, cls=AlertT.warning)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
selection_preview = Div(
|
||||
# Selection component - show checkboxes if animals provided and > 1
|
||||
selection_component = None
|
||||
subset_mode = False
|
||||
if animals and len(animals) > 1:
|
||||
# Show checkbox list for subset selection
|
||||
selection_component = Div(
|
||||
P("Select animals for this action:", cls="text-sm text-stone-400 mb-2"),
|
||||
animal_checkbox_list(animals, resolved_ids),
|
||||
cls="mb-4",
|
||||
)
|
||||
subset_mode = True
|
||||
elif resolved_count > 0:
|
||||
# Fallback to simple count display
|
||||
selection_component = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
" animals selected",
|
||||
@@ -1187,7 +1243,7 @@ def outcome_form(
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
selection_component = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
@@ -1267,7 +1323,7 @@ def outcome_form(
|
||||
return Form(
|
||||
H2("Record Outcome", cls="text-xl font-bold mb-4"),
|
||||
error_component,
|
||||
selection_preview,
|
||||
selection_component,
|
||||
# Filter field
|
||||
LabelInput(
|
||||
label="Filter (DSL)",
|
||||
@@ -1307,6 +1363,7 @@ 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),
|
||||
|
||||
@@ -223,7 +223,7 @@ def animal_timeline_list(timeline: list[TimelineEvent]) -> Ul:
|
||||
|
||||
|
||||
def timeline_event_item(event: TimelineEvent) -> Li:
|
||||
"""Single timeline event item."""
|
||||
"""Single timeline event item - clickable to view details."""
|
||||
badge_cls = event_type_badge_class(event.event_type)
|
||||
summary_text = format_timeline_summary(event.event_type, event.summary)
|
||||
time_str = format_timestamp(event.ts_utc)
|
||||
@@ -236,7 +236,11 @@ def timeline_event_item(event: TimelineEvent) -> Li:
|
||||
),
|
||||
P(summary_text, cls="text-sm text-stone-300"),
|
||||
P(f"by {event.actor}", cls="text-xs text-stone-500"),
|
||||
cls="py-3 border-b border-stone-700 last:border-0",
|
||||
cls="py-3 border-b border-stone-700 last:border-0 cursor-pointer "
|
||||
"hover:bg-stone-800/50 -mx-2 px-2 rounded transition-colors",
|
||||
hx_get=f"/events/{event.event_id}",
|
||||
hx_target="#event-slide-over",
|
||||
hx_swap="innerHTML",
|
||||
)
|
||||
|
||||
|
||||
|
||||
192
src/animaltrack/web/templates/animal_select.py
Normal file
192
src/animaltrack/web/templates/animal_select.py
Normal file
@@ -0,0 +1,192 @@
|
||||
# ABOUTME: Animal selection component with checkboxes.
|
||||
# ABOUTME: Renders a list of animals with checkboxes for subset selection.
|
||||
|
||||
from fasthtml.common import Div, Input, Label, P, Span
|
||||
|
||||
from animaltrack.repositories.animals import AnimalListItem
|
||||
|
||||
|
||||
def animal_checkbox_list(
|
||||
animals: list[AnimalListItem],
|
||||
selected_ids: list[str] | None = None,
|
||||
max_display: int = 50,
|
||||
) -> Div:
|
||||
"""Render a list of animals with checkboxes for selection.
|
||||
|
||||
Args:
|
||||
animals: List of animals to display.
|
||||
selected_ids: IDs that should be pre-checked. If None, all are checked.
|
||||
max_display: Maximum animals to show before truncating.
|
||||
|
||||
Returns:
|
||||
Div containing the checkbox list.
|
||||
"""
|
||||
if selected_ids is None:
|
||||
selected_ids = [a.animal_id for a in animals]
|
||||
|
||||
selected_set = set(selected_ids)
|
||||
|
||||
items = []
|
||||
for animal in animals[:max_display]:
|
||||
is_checked = animal.animal_id in selected_set
|
||||
display_name = animal.nickname or animal.animal_id[:8] + "..."
|
||||
|
||||
items.append(
|
||||
Label(
|
||||
Input(
|
||||
type="checkbox",
|
||||
name="selected_ids",
|
||||
value=animal.animal_id,
|
||||
checked=is_checked,
|
||||
cls="uk-checkbox mr-2",
|
||||
hx_on_change="updateSelectionCount()",
|
||||
),
|
||||
Span(display_name, cls="text-stone-200"),
|
||||
Span(
|
||||
f" ({animal.species_code}, {animal.location_name})",
|
||||
cls="text-stone-500 text-sm",
|
||||
),
|
||||
cls="flex items-center py-1 hover:bg-stone-800/30 px-2 rounded cursor-pointer",
|
||||
)
|
||||
)
|
||||
|
||||
# Show truncation message if needed
|
||||
if len(animals) > max_display:
|
||||
remaining = len(animals) - max_display
|
||||
items.append(
|
||||
P(
|
||||
f"... and {remaining} more",
|
||||
cls="text-stone-500 text-sm pl-6 py-1",
|
||||
)
|
||||
)
|
||||
|
||||
# Selection count display
|
||||
count_display = Div(
|
||||
Span(
|
||||
f"{len(selected_ids)} of {len(animals)} selected",
|
||||
id="selection-count-text",
|
||||
cls="text-stone-400 text-sm",
|
||||
),
|
||||
Div(
|
||||
Span(
|
||||
"Select all",
|
||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm mr-3",
|
||||
hx_on_click="selectAllAnimals(true)",
|
||||
),
|
||||
Span(
|
||||
"Select none",
|
||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm",
|
||||
hx_on_click="selectAllAnimals(false)",
|
||||
),
|
||||
cls="flex gap-2",
|
||||
),
|
||||
cls="flex justify-between items-center py-2 border-b border-stone-700 mb-2",
|
||||
)
|
||||
|
||||
return Div(
|
||||
count_display,
|
||||
Div(
|
||||
*items,
|
||||
cls="max-h-64 overflow-y-auto",
|
||||
),
|
||||
# Hidden field for roster_hash - will be updated via JS
|
||||
Input(type="hidden", name="roster_hash", id="roster-hash-field"),
|
||||
# Script for selection management
|
||||
selection_script(len(animals)),
|
||||
id="animal-selection-list",
|
||||
cls="border border-stone-700 rounded-lg p-3 bg-stone-900/50",
|
||||
)
|
||||
|
||||
|
||||
def selection_script(total_count: int) -> Div:
|
||||
"""JavaScript for managing selection state.
|
||||
|
||||
Args:
|
||||
total_count: Total number of animals in the list.
|
||||
|
||||
Returns:
|
||||
Script element with selection management code.
|
||||
"""
|
||||
from fasthtml.common import Script
|
||||
|
||||
return Script(f"""
|
||||
function updateSelectionCount() {{
|
||||
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]');
|
||||
var checked = Array.from(checkboxes).filter(cb => cb.checked).length;
|
||||
var countText = document.getElementById('selection-count-text');
|
||||
if (countText) {{
|
||||
countText.textContent = checked + ' of {total_count} selected';
|
||||
}}
|
||||
// Trigger hash recomputation if needed
|
||||
computeSelectionHash();
|
||||
}}
|
||||
|
||||
function selectAllAnimals(selectAll) {{
|
||||
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]');
|
||||
checkboxes.forEach(function(cb) {{
|
||||
cb.checked = selectAll;
|
||||
}});
|
||||
updateSelectionCount();
|
||||
}}
|
||||
|
||||
function getSelectedIds() {{
|
||||
var checkboxes = document.querySelectorAll('#animal-selection-list input[name="selected_ids"]:checked');
|
||||
return Array.from(checkboxes).map(cb => cb.value);
|
||||
}}
|
||||
|
||||
function computeSelectionHash() {{
|
||||
// Get selected IDs and compute hash via API
|
||||
var selectedIds = getSelectedIds();
|
||||
var fromLocationId = document.querySelector('input[name="from_location_id"]');
|
||||
var fromLoc = fromLocationId ? fromLocationId.value : '';
|
||||
|
||||
fetch('/api/compute-hash', {{
|
||||
method: 'POST',
|
||||
headers: {{'Content-Type': 'application/json'}},
|
||||
body: JSON.stringify({{
|
||||
selected_ids: selectedIds,
|
||||
from_location_id: fromLoc
|
||||
}})
|
||||
}})
|
||||
.then(response => response.json())
|
||||
.then(data => {{
|
||||
var hashField = document.getElementById('roster-hash-field');
|
||||
if (hashField) {{
|
||||
hashField.value = data.roster_hash;
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
|
||||
// Initialize hash on load
|
||||
document.addEventListener('DOMContentLoaded', function() {{
|
||||
computeSelectionHash();
|
||||
}});
|
||||
""")
|
||||
|
||||
|
||||
def selection_summary(count: int, total: int) -> Div:
|
||||
"""Render a selection summary when all animals are selected.
|
||||
|
||||
Shows "X animals selected" with option to customize.
|
||||
|
||||
Args:
|
||||
count: Number of selected animals.
|
||||
total: Total animals matching filter.
|
||||
|
||||
Returns:
|
||||
Div with selection summary.
|
||||
"""
|
||||
return Div(
|
||||
P(
|
||||
f"{count} animals selected",
|
||||
cls="text-stone-300",
|
||||
),
|
||||
Span(
|
||||
"Customize selection",
|
||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline text-sm",
|
||||
hx_get="", # Will be set by caller
|
||||
hx_target="#selection-container",
|
||||
hx_swap="innerHTML",
|
||||
),
|
||||
cls="flex justify-between items-center py-2",
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
# ABOUTME: Base HTML template for AnimalTrack pages.
|
||||
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
|
||||
|
||||
from fasthtml.common import Container, Div, Script, Title
|
||||
from fasthtml.common import Container, Div, Script, Style, Title
|
||||
from starlette.requests import Request
|
||||
|
||||
from animaltrack.models.reference import UserRole
|
||||
@@ -14,65 +14,84 @@ from animaltrack.web.templates.sidebar import (
|
||||
)
|
||||
|
||||
|
||||
def toast_container():
|
||||
"""Create a toast container for displaying notifications.
|
||||
def EventSlideOverStyles(): # noqa: N802
|
||||
"""CSS styles for event detail slide-over panel."""
|
||||
return Style("""
|
||||
/* Event slide-over panel - slides from right */
|
||||
#event-slide-over {
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease-out;
|
||||
}
|
||||
|
||||
This container holds toast notifications that appear in the top-right corner.
|
||||
Toasts are triggered via HTMX events (HX-Trigger header with showToast).
|
||||
"""
|
||||
#event-slide-over.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Backdrop overlay for event panel */
|
||||
#event-backdrop {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
#event-backdrop.open {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
def EventSlideOverScript(): # noqa: N802
|
||||
"""JavaScript for event slide-over panel open/close behavior."""
|
||||
return Script("""
|
||||
function openEventPanel() {
|
||||
document.getElementById('event-slide-over').classList.add('open');
|
||||
document.getElementById('event-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the panel for keyboard events
|
||||
document.getElementById('event-slide-over').focus();
|
||||
}
|
||||
|
||||
function closeEventPanel() {
|
||||
document.getElementById('event-slide-over').classList.remove('open');
|
||||
document.getElementById('event-backdrop').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// HTMX event: after loading event content, open the panel
|
||||
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||
if (evt.detail.target.id === 'event-slide-over') {
|
||||
openEventPanel();
|
||||
}
|
||||
});
|
||||
""")
|
||||
|
||||
|
||||
def EventSlideOver(): # noqa: N802
|
||||
"""Event detail slide-over panel container."""
|
||||
return Div(
|
||||
id="toast-container",
|
||||
cls="toast toast-end toast-top z-50",
|
||||
# Backdrop
|
||||
Div(
|
||||
id="event-backdrop",
|
||||
cls="fixed inset-0 bg-black/60 z-40",
|
||||
hx_on_click="closeEventPanel()",
|
||||
),
|
||||
# Slide-over panel
|
||||
Div(
|
||||
# Content loaded via HTMX
|
||||
Div(
|
||||
id="event-panel-content",
|
||||
cls="h-full",
|
||||
),
|
||||
id="event-slide-over",
|
||||
cls="fixed top-0 right-0 bottom-0 w-96 max-w-full bg-[#141413] z-50 "
|
||||
"shadow-2xl border-l border-stone-700 overflow-hidden",
|
||||
tabindex="-1",
|
||||
hx_on_keydown="if(event.key==='Escape') closeEventPanel()",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def toast_script():
|
||||
"""JavaScript to handle showToast events from HTMX.
|
||||
|
||||
Listens for the showToast event and creates toast notifications
|
||||
that auto-dismiss after 5 seconds.
|
||||
"""
|
||||
script = """
|
||||
document.body.addEventListener('showToast', function(evt) {
|
||||
var message = evt.detail.message || 'Action completed';
|
||||
var type = evt.detail.type || 'success';
|
||||
|
||||
// Create alert element with appropriate styling
|
||||
var alertClass = 'alert shadow-lg mb-2 ';
|
||||
if (type === 'success') {
|
||||
alertClass += 'alert-success';
|
||||
} else if (type === 'error') {
|
||||
alertClass += 'alert-error';
|
||||
} else if (type === 'warning') {
|
||||
alertClass += 'alert-warning';
|
||||
} else {
|
||||
alertClass += 'alert-info';
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = alertClass;
|
||||
toast.innerHTML = '<span>' + message + '</span>';
|
||||
|
||||
var container = document.getElementById('toast-container');
|
||||
if (container) {
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto-remove after 5 seconds
|
||||
setTimeout(function() {
|
||||
toast.style.opacity = '0';
|
||||
toast.style.transition = 'opacity 0.5s';
|
||||
setTimeout(function() {
|
||||
if (toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}, 500);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
"""
|
||||
return Script(script)
|
||||
|
||||
|
||||
def page(
|
||||
content,
|
||||
title: str = "AnimalTrack",
|
||||
@@ -88,7 +107,7 @@ def page(
|
||||
- Desktop sidebar (hidden on mobile)
|
||||
- Mobile bottom nav (hidden on desktop)
|
||||
- Mobile menu drawer
|
||||
- Toast container for notifications
|
||||
- Event detail slide-over panel
|
||||
|
||||
Args:
|
||||
content: Page content (FT components)
|
||||
@@ -104,12 +123,15 @@ def page(
|
||||
Title(title),
|
||||
BottomNavStyles(),
|
||||
SidebarStyles(),
|
||||
toast_script(),
|
||||
EventSlideOverStyles(),
|
||||
SidebarScript(),
|
||||
EventSlideOverScript(),
|
||||
# Desktop sidebar
|
||||
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
|
||||
# Mobile menu drawer
|
||||
MenuDrawer(user_role=user_role),
|
||||
# Event detail slide-over panel
|
||||
EventSlideOver(),
|
||||
# Main content with responsive padding/margin
|
||||
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
|
||||
# md:ml-60 to offset for desktop sidebar
|
||||
@@ -120,7 +142,6 @@ def page(
|
||||
hx_target="body",
|
||||
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||
),
|
||||
toast_container(),
|
||||
# Mobile bottom nav
|
||||
BottomNav(active_id=active_nav),
|
||||
)
|
||||
|
||||
322
src/animaltrack/web/templates/event_detail.py
Normal file
322
src/animaltrack/web/templates/event_detail.py
Normal file
@@ -0,0 +1,322 @@
|
||||
# ABOUTME: Template for event detail slide-over panel.
|
||||
# ABOUTME: Renders event information, payload details, and affected animals.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from fasthtml.common import H3, A, Button, Div, Li, P, Span, Ul
|
||||
|
||||
from animaltrack.models.events import Event
|
||||
|
||||
|
||||
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:%S")
|
||||
|
||||
|
||||
def event_detail_panel(
|
||||
event: Event,
|
||||
affected_animals: list[dict[str, Any]],
|
||||
is_tombstoned: bool = False,
|
||||
location_names: dict[str, str] | None = None,
|
||||
) -> Div:
|
||||
"""Event detail slide-over panel content.
|
||||
|
||||
Args:
|
||||
event: The event to display.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
Div containing the panel content.
|
||||
"""
|
||||
if location_names is None:
|
||||
location_names = {}
|
||||
|
||||
return Div(
|
||||
# Header with close button
|
||||
Div(
|
||||
Div(
|
||||
event_type_badge(event.type),
|
||||
Span(format_timestamp(event.ts_utc), cls="text-sm text-stone-400 ml-2"),
|
||||
cls="flex items-center",
|
||||
),
|
||||
Button(
|
||||
"×",
|
||||
hx_on_click="closeEventPanel()",
|
||||
cls="text-2xl text-stone-400 hover:text-stone-200 p-2 -mr-2",
|
||||
type="button",
|
||||
),
|
||||
cls="flex items-center justify-between p-4 border-b border-stone-700",
|
||||
),
|
||||
# Tombstone warning
|
||||
tombstone_warning() if is_tombstoned else None,
|
||||
# Event metadata
|
||||
Div(
|
||||
metadata_item("Event ID", event.id),
|
||||
metadata_item("Actor", event.actor),
|
||||
metadata_item("Version", str(event.version)),
|
||||
cls="p-4 space-y-2 border-b border-stone-700",
|
||||
),
|
||||
# Payload details
|
||||
payload_section(event.type, event.payload, location_names),
|
||||
# Entity references
|
||||
entity_refs_section(event.entity_refs, location_names),
|
||||
# Affected animals
|
||||
affected_animals_section(affected_animals),
|
||||
id="event-panel-content",
|
||||
cls="bg-[#141413] h-full overflow-y-auto",
|
||||
)
|
||||
|
||||
|
||||
def event_type_badge(event_type: str) -> Span:
|
||||
"""Badge for event type with color coding."""
|
||||
type_colors = {
|
||||
"AnimalCohortCreated": "bg-green-900/50 text-green-300",
|
||||
"AnimalMoved": "bg-purple-900/50 text-purple-300",
|
||||
"AnimalAttributesUpdated": "bg-blue-900/50 text-blue-300",
|
||||
"AnimalTagged": "bg-indigo-900/50 text-indigo-300",
|
||||
"AnimalTagEnded": "bg-slate-700 text-slate-300",
|
||||
"ProductCollected": "bg-amber-900/50 text-amber-300",
|
||||
"HatchRecorded": "bg-pink-900/50 text-pink-300",
|
||||
"AnimalOutcome": "bg-red-900/50 text-red-300",
|
||||
"AnimalPromoted": "bg-cyan-900/50 text-cyan-300",
|
||||
"AnimalMerged": "bg-slate-600 text-slate-300",
|
||||
"AnimalStatusCorrected": "bg-orange-900/50 text-orange-300",
|
||||
"FeedPurchased": "bg-lime-900/50 text-lime-300",
|
||||
"FeedGiven": "bg-teal-900/50 text-teal-300",
|
||||
"ProductSold": "bg-yellow-900/50 text-yellow-300",
|
||||
}
|
||||
color_cls = type_colors.get(event_type, "bg-gray-700 text-gray-300")
|
||||
return Span(event_type, cls=f"text-sm font-medium px-2 py-1 rounded {color_cls}")
|
||||
|
||||
|
||||
def tombstone_warning() -> Div:
|
||||
"""Warning that event has been deleted."""
|
||||
return Div(
|
||||
P(
|
||||
"⚠ This event has been deleted.",
|
||||
cls="text-sm text-red-400",
|
||||
),
|
||||
cls="p-4 bg-red-900/20 border-b border-red-800",
|
||||
)
|
||||
|
||||
|
||||
def metadata_item(label: str, value: str) -> Div:
|
||||
"""Single metadata key-value item."""
|
||||
return Div(
|
||||
Span(label + ":", cls="text-stone-500 text-sm"),
|
||||
Span(value, cls="text-stone-300 text-sm ml-2 font-mono"),
|
||||
cls="flex",
|
||||
)
|
||||
|
||||
|
||||
def payload_section(
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
location_names: dict[str, str],
|
||||
) -> Div:
|
||||
"""Section showing event payload details."""
|
||||
items = render_payload_items(event_type, payload, location_names)
|
||||
|
||||
if not items:
|
||||
return Div()
|
||||
|
||||
return Div(
|
||||
H3("Details", cls="text-sm font-semibold text-stone-400 mb-2"),
|
||||
*items,
|
||||
cls="p-4 border-b border-stone-700",
|
||||
)
|
||||
|
||||
|
||||
def render_payload_items(
|
||||
event_type: str,
|
||||
payload: dict[str, Any],
|
||||
location_names: dict[str, str],
|
||||
) -> list:
|
||||
"""Render payload items based on event type."""
|
||||
items = []
|
||||
|
||||
if event_type == "AnimalCohortCreated":
|
||||
if "species" in payload:
|
||||
items.append(payload_item("Species", payload["species"]))
|
||||
if "count" in payload:
|
||||
items.append(payload_item("Count", str(payload["count"])))
|
||||
if "origin" in payload:
|
||||
items.append(payload_item("Origin", payload["origin"]))
|
||||
if "sex" in payload:
|
||||
items.append(payload_item("Sex", payload["sex"]))
|
||||
if "life_stage" in payload:
|
||||
items.append(payload_item("Life Stage", payload["life_stage"]))
|
||||
|
||||
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))
|
||||
|
||||
elif event_type == "AnimalTagged":
|
||||
if "tag" in payload:
|
||||
items.append(payload_item("Tag", payload["tag"]))
|
||||
|
||||
elif event_type == "AnimalTagEnded":
|
||||
if "tag" in payload:
|
||||
items.append(payload_item("Tag", payload["tag"]))
|
||||
if "reason" in payload:
|
||||
items.append(payload_item("Reason", payload["reason"]))
|
||||
|
||||
elif event_type == "ProductCollected":
|
||||
if "product_code" in payload:
|
||||
items.append(payload_item("Product", payload["product_code"]))
|
||||
if "quantity" in payload:
|
||||
items.append(payload_item("Quantity", str(payload["quantity"])))
|
||||
|
||||
elif event_type == "AnimalOutcome":
|
||||
if "outcome" in payload:
|
||||
items.append(payload_item("Outcome", payload["outcome"]))
|
||||
if "reason" in payload:
|
||||
items.append(payload_item("Reason", payload["reason"]))
|
||||
|
||||
elif event_type == "AnimalPromoted":
|
||||
if "nickname" in payload:
|
||||
items.append(payload_item("Nickname", payload["nickname"]))
|
||||
|
||||
elif event_type == "AnimalStatusCorrected":
|
||||
if "old_status" in payload:
|
||||
items.append(payload_item("Old Status", payload["old_status"]))
|
||||
if "new_status" in payload:
|
||||
items.append(payload_item("New Status", payload["new_status"]))
|
||||
if "reason" in payload:
|
||||
items.append(payload_item("Reason", payload["reason"]))
|
||||
|
||||
elif event_type == "FeedPurchased":
|
||||
if "feed_code" in payload:
|
||||
items.append(payload_item("Feed", payload["feed_code"]))
|
||||
if "quantity_grams" in payload:
|
||||
kg = payload["quantity_grams"] / 1000
|
||||
items.append(payload_item("Quantity", f"{kg:.3f} kg"))
|
||||
if "cost_cents" in payload:
|
||||
cost = payload["cost_cents"] / 100
|
||||
items.append(payload_item("Cost", f"${cost:.2f}"))
|
||||
|
||||
elif event_type == "FeedGiven":
|
||||
if "feed_code" in payload:
|
||||
items.append(payload_item("Feed", payload["feed_code"]))
|
||||
if "quantity_grams" in payload:
|
||||
kg = payload["quantity_grams"] / 1000
|
||||
items.append(payload_item("Quantity", f"{kg:.3f} kg"))
|
||||
|
||||
elif event_type == "ProductSold":
|
||||
if "product_code" in payload:
|
||||
items.append(payload_item("Product", payload["product_code"]))
|
||||
if "quantity" in payload:
|
||||
items.append(payload_item("Quantity", str(payload["quantity"])))
|
||||
if "price_cents" in payload:
|
||||
price = payload["price_cents"] / 100
|
||||
items.append(payload_item("Price", f"${price:.2f}"))
|
||||
|
||||
elif event_type == "HatchRecorded":
|
||||
if "clutch_size" in payload:
|
||||
items.append(payload_item("Clutch Size", str(payload["clutch_size"])))
|
||||
if "hatch_count" in payload:
|
||||
items.append(payload_item("Hatched", str(payload["hatch_count"])))
|
||||
|
||||
# Fallback: show raw payload for unknown types
|
||||
if not items and payload:
|
||||
for key, value in payload.items():
|
||||
if not key.startswith("_"):
|
||||
items.append(payload_item(key, str(value)))
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def payload_item(label: str, value: str) -> Div:
|
||||
"""Single payload item."""
|
||||
return Div(
|
||||
Span(label + ":", cls="text-stone-500 text-sm min-w-[100px]"),
|
||||
Span(value, cls="text-stone-300 text-sm"),
|
||||
cls="flex gap-2",
|
||||
)
|
||||
|
||||
|
||||
def entity_refs_section(
|
||||
entity_refs: dict[str, Any],
|
||||
location_names: dict[str, str],
|
||||
) -> Div:
|
||||
"""Section showing entity references."""
|
||||
if not entity_refs:
|
||||
return Div()
|
||||
|
||||
items = []
|
||||
for key, value in entity_refs.items():
|
||||
# Skip animal_ids - shown in affected animals 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 "")
|
||||
|
||||
if isinstance(value, list):
|
||||
display_value = f"{len(value)} items"
|
||||
elif isinstance(value, str) and len(value) > 20:
|
||||
display_value = value[:8] + "..."
|
||||
|
||||
items.append(payload_item(key.replace("_", " ").title(), str(display_value)))
|
||||
|
||||
if not items:
|
||||
return Div()
|
||||
|
||||
return Div(
|
||||
H3("References", cls="text-sm font-semibold text-stone-400 mb-2"),
|
||||
*items,
|
||||
cls="p-4 border-b border-stone-700",
|
||||
)
|
||||
|
||||
|
||||
def affected_animals_section(animals: list[dict[str, Any]]) -> Div:
|
||||
"""Section showing affected animals."""
|
||||
if not animals:
|
||||
return Div()
|
||||
|
||||
animal_items = []
|
||||
for animal in animals[:20]: # Limit display
|
||||
display_name = animal.get("nickname") or animal["id"][:8] + "..."
|
||||
animal_items.append(
|
||||
Li(
|
||||
A(
|
||||
Span(display_name, cls="text-amber-500 hover:underline"),
|
||||
Span(
|
||||
f" ({animal.get('species_name', '')})",
|
||||
cls="text-stone-500 text-xs",
|
||||
),
|
||||
href=f"/animals/{animal['id']}",
|
||||
),
|
||||
cls="py-1",
|
||||
)
|
||||
)
|
||||
|
||||
more_count = len(animals) - 20
|
||||
if more_count > 0:
|
||||
animal_items.append(
|
||||
Li(
|
||||
Span(f"... and {more_count} more", cls="text-stone-500 text-sm"),
|
||||
cls="py-1",
|
||||
)
|
||||
)
|
||||
|
||||
return Div(
|
||||
H3(
|
||||
f"Affected Animals ({len(animals)})",
|
||||
cls="text-sm font-semibold text-stone-400 mb-2",
|
||||
),
|
||||
Ul(*animal_items, cls="space-y-1"),
|
||||
cls="p-4",
|
||||
)
|
||||
@@ -24,6 +24,7 @@ def move_form(
|
||||
from_location_name: str | None = None,
|
||||
error: str | None = None,
|
||||
action: Callable[..., Any] | str = "/actions/animal-move",
|
||||
animals: list | None = None,
|
||||
) -> Form:
|
||||
"""Create the Move Animals form.
|
||||
|
||||
@@ -38,12 +39,17 @@ def move_form(
|
||||
from_location_name: Name of source location for display.
|
||||
error: Optional error message to display.
|
||||
action: Route function or URL string for form submission.
|
||||
animals: List of AnimalListItem for checkbox selection (optional).
|
||||
|
||||
Returns:
|
||||
Form component for moving animals.
|
||||
"""
|
||||
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||
|
||||
if resolved_ids is None:
|
||||
resolved_ids = []
|
||||
if animals is None:
|
||||
animals = []
|
||||
|
||||
# Build destination location options (exclude from_location if set)
|
||||
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
|
||||
@@ -59,11 +65,21 @@ def move_form(
|
||||
cls=AlertT.warning,
|
||||
)
|
||||
|
||||
# Selection preview component
|
||||
selection_preview = None
|
||||
if resolved_count > 0:
|
||||
# Selection component - show checkboxes if animals provided and > 1
|
||||
selection_component = 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 ""
|
||||
selection_preview = Div(
|
||||
selection_component = Div(
|
||||
P(f"Select animals to move{location_info}:", cls="text-sm text-stone-400 mb-2"),
|
||||
animal_checkbox_list(animals, resolved_ids),
|
||||
cls="mb-4",
|
||||
)
|
||||
subset_mode = True
|
||||
elif resolved_count > 0:
|
||||
location_info = f" from {from_location_name}" if from_location_name else ""
|
||||
selection_component = Div(
|
||||
P(
|
||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||
f" animals selected{location_info}",
|
||||
@@ -72,7 +88,7 @@ def move_form(
|
||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||
)
|
||||
elif filter_str:
|
||||
selection_preview = Div(
|
||||
selection_component = Div(
|
||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
||||
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||
)
|
||||
@@ -94,8 +110,8 @@ def move_form(
|
||||
value=filter_str,
|
||||
placeholder='e.g., location:"Strip 1" species:duck',
|
||||
),
|
||||
# Selection preview
|
||||
selection_preview,
|
||||
# Selection component (checkboxes or simple count)
|
||||
selection_component,
|
||||
# Destination dropdown
|
||||
LabelSelect(
|
||||
*location_options,
|
||||
@@ -118,6 +134,7 @@ 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),
|
||||
|
||||
@@ -77,6 +77,8 @@ def SidebarScript(): # noqa: N802
|
||||
document.getElementById('menu-drawer').classList.add('open');
|
||||
document.getElementById('menu-backdrop').classList.add('open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
// Focus the drawer for keyboard events
|
||||
document.getElementById('menu-drawer').focus();
|
||||
}
|
||||
|
||||
function closeMenuDrawer() {
|
||||
@@ -84,13 +86,6 @@ def SidebarScript(): # noqa: N802
|
||||
document.getElementById('menu-backdrop').classList.remove('open');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
// Close on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeMenuDrawer();
|
||||
}
|
||||
});
|
||||
""")
|
||||
|
||||
|
||||
@@ -255,7 +250,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
||||
Div(
|
||||
id="menu-backdrop",
|
||||
cls="fixed inset-0 bg-black/60 z-40",
|
||||
onclick="closeMenuDrawer()",
|
||||
hx_on_click="closeMenuDrawer()",
|
||||
),
|
||||
# Drawer panel
|
||||
Div(
|
||||
@@ -264,7 +259,7 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
||||
Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"),
|
||||
Button(
|
||||
_close_icon(),
|
||||
onclick="closeMenuDrawer()",
|
||||
hx_on_click="closeMenuDrawer()",
|
||||
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
||||
type="button",
|
||||
),
|
||||
@@ -277,6 +272,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
||||
),
|
||||
id="menu-drawer",
|
||||
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
|
||||
tabindex="-1",
|
||||
hx_on_keydown="if(event.key==='Escape') closeMenuDrawer()",
|
||||
),
|
||||
cls="md:hidden",
|
||||
)
|
||||
|
||||
@@ -149,7 +149,7 @@ class TestCohortCreationSuccess:
|
||||
assert count_after == count_before + 3
|
||||
|
||||
def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id):
|
||||
"""Successful cohort creation returns HX-Trigger with toast."""
|
||||
"""Successful cohort creation stores toast in session."""
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
@@ -164,8 +164,20 @@ class TestCohortCreationSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
# Toast is stored in session cookie (FastHTML's add_toast mechanism)
|
||||
# The session cookie contains base64-encoded toast data with "toasts" key
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
# Base64 decode contains toast message (eyJ0b2FzdHMi... = {"toasts"...)
|
||||
import base64
|
||||
|
||||
# Extract base64 portion from cookie value
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
# FastHTML uses itsdangerous, so format is base64.timestamp.signature
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Created 2 duck" in decoded
|
||||
|
||||
|
||||
class TestCohortCreationValidation:
|
||||
@@ -363,7 +375,7 @@ class TestHatchRecordingSuccess:
|
||||
assert count_at_nursery >= 3
|
||||
|
||||
def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id):
|
||||
"""Successful hatch recording returns HX-Trigger with toast."""
|
||||
"""Successful hatch recording stores toast in session."""
|
||||
resp = client.post(
|
||||
"/actions/hatch-recorded",
|
||||
data={
|
||||
@@ -375,8 +387,16 @@ class TestHatchRecordingSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
# Toast is stored in session cookie (FastHTML's add_toast mechanism)
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
import base64
|
||||
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Recorded 2 hatchling" in decoded
|
||||
|
||||
|
||||
class TestHatchRecordingValidation:
|
||||
@@ -709,7 +729,8 @@ class TestTagAddSuccess:
|
||||
assert tag_count >= len(animals_for_tagging)
|
||||
|
||||
def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging):
|
||||
"""Successful tag add returns HX-Trigger with toast."""
|
||||
"""Successful tag add stores toast in session."""
|
||||
import base64
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
@@ -730,8 +751,14 @@ class TestTagAddSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
# Toast is stored in session cookie
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Tagged" in decoded and "test-tag-toast" in decoded
|
||||
|
||||
|
||||
class TestTagAddValidation:
|
||||
@@ -898,7 +925,8 @@ class TestTagEndSuccess:
|
||||
assert open_after == 0
|
||||
|
||||
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
|
||||
"""Successful tag end returns HX-Trigger with toast."""
|
||||
"""Successful tag end stores toast in session."""
|
||||
import base64
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
@@ -919,8 +947,14 @@ class TestTagEndSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
# Toast is stored in session cookie
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Ended tag" in decoded and "test-end-tag" in decoded
|
||||
|
||||
|
||||
class TestTagEndValidation:
|
||||
@@ -1069,7 +1103,8 @@ class TestAttrsSuccess:
|
||||
assert adult_count == len(animals_for_tagging)
|
||||
|
||||
def test_attrs_success_returns_toast(self, client, seeded_db, animals_for_tagging):
|
||||
"""Successful attrs update returns HX-Trigger with toast."""
|
||||
"""Successful attrs update stores toast in session."""
|
||||
import base64
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
@@ -1090,8 +1125,14 @@ class TestAttrsSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
# Toast is stored in session cookie
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Updated attributes" in decoded
|
||||
|
||||
|
||||
class TestAttrsValidation:
|
||||
@@ -1239,7 +1280,8 @@ class TestOutcomeSuccess:
|
||||
assert harvested_count == len(animals_for_tagging)
|
||||
|
||||
def test_outcome_success_returns_toast(self, client, seeded_db, animals_for_tagging):
|
||||
"""Successful outcome recording returns HX-Trigger with toast."""
|
||||
"""Successful outcome recording stores toast in session."""
|
||||
import base64
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
@@ -1260,8 +1302,14 @@ class TestOutcomeSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
# Toast is stored in session cookie
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Recorded sold" in decoded
|
||||
|
||||
|
||||
class TestOutcomeValidation:
|
||||
|
||||
@@ -198,7 +198,7 @@ class TestMoveAnimalSuccess:
|
||||
location_strip2_id,
|
||||
ducks_at_strip1,
|
||||
):
|
||||
"""Successful move returns HX-Trigger with toast."""
|
||||
"""Successful move returns session cookie with toast."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
filter_str = 'location:"Strip 1"'
|
||||
filter_ast = parse_filter(filter_str)
|
||||
@@ -219,8 +219,16 @@ class TestMoveAnimalSuccess:
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
assert "set-cookie" in resp.headers
|
||||
session_cookie = resp.headers["set-cookie"]
|
||||
assert "session_=" in session_cookie
|
||||
# Base64 decode contains toast message
|
||||
import base64
|
||||
|
||||
cookie_value = session_cookie.split("session_=")[1].split(";")[0]
|
||||
base64_data = cookie_value.split(".")[0]
|
||||
decoded = base64.b64decode(base64_data).decode()
|
||||
assert "Moved 5 animals to Strip 2" in decoded
|
||||
|
||||
def test_move_success_resets_form(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user