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],
|
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(
|
def list_animals(
|
||||||
self,
|
self,
|
||||||
filter_str: str = "",
|
filter_str: str = "",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ class SelectionContext:
|
|||||||
from_location_id: str | None # For move operations (included in hash)
|
from_location_id: str | None # For move operations (included in hash)
|
||||||
confirmed: bool = False # Override on mismatch
|
confirmed: bool = False # Override on mismatch
|
||||||
resolver_version: str = "v1" # Fixed version string
|
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
|
@dataclass
|
||||||
@@ -101,6 +103,10 @@ def validate_selection(
|
|||||||
confirmed=True. Returns valid=False with diff if mismatch and not
|
confirmed=True. Returns valid=False with diff if mismatch and not
|
||||||
confirmed.
|
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:
|
Args:
|
||||||
db: Database connection.
|
db: Database connection.
|
||||||
context: SelectionContext with client's filter, IDs, and hash.
|
context: SelectionContext with client's filter, IDs, and hash.
|
||||||
@@ -112,6 +118,11 @@ def validate_selection(
|
|||||||
filter_ast = parse_filter(context.filter)
|
filter_ast = parse_filter(context.filter)
|
||||||
resolution = resolve_filter(db, filter_ast, context.ts_utc)
|
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)
|
# Compute server's hash (including from_location_id if provided)
|
||||||
server_hash = compute_roster_hash(
|
server_hash = compute_roster_hash(
|
||||||
resolution.animal_ids,
|
resolution.animal_ids,
|
||||||
@@ -147,3 +158,71 @@ def validate_selection(
|
|||||||
roster_hash=server_hash,
|
roster_hash=server_hash,
|
||||||
diff=diff,
|
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 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 monsterui.all import Theme
|
||||||
from starlette.middleware import Middleware
|
from starlette.middleware import Middleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
@@ -22,6 +22,7 @@ from animaltrack.web.middleware import (
|
|||||||
from animaltrack.web.routes import (
|
from animaltrack.web.routes import (
|
||||||
actions_router,
|
actions_router,
|
||||||
animals_router,
|
animals_router,
|
||||||
|
api_router,
|
||||||
eggs_router,
|
eggs_router,
|
||||||
events_router,
|
events_router,
|
||||||
feed_router,
|
feed_router,
|
||||||
@@ -143,6 +144,9 @@ def create_app(
|
|||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.db = db
|
app.state.db = db
|
||||||
|
|
||||||
|
# Setup toast notifications with 5 second duration
|
||||||
|
setup_toasts(app, duration=5000)
|
||||||
|
|
||||||
# Register exception handlers for auth errors
|
# Register exception handlers for auth errors
|
||||||
async def authentication_error_handler(request, exc):
|
async def authentication_error_handler(request, exc):
|
||||||
return PlainTextResponse(str(exc) or "Authentication required", status_code=401)
|
return PlainTextResponse(str(exc) or "Authentication required", status_code=401)
|
||||||
@@ -157,6 +161,7 @@ def create_app(
|
|||||||
health_router.to_app(app)
|
health_router.to_app(app)
|
||||||
actions_router.to_app(app)
|
actions_router.to_app(app)
|
||||||
animals_router.to_app(app)
|
animals_router.to_app(app)
|
||||||
|
api_router.to_app(app)
|
||||||
eggs_router.to_app(app)
|
eggs_router.to_app(app)
|
||||||
events_router.to_app(app)
|
events_router.to_app(app)
|
||||||
feed_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.actions import ar as actions_router
|
||||||
from animaltrack.web.routes.animals import ar as animals_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.eggs import ar as eggs_router
|
||||||
from animaltrack.web.routes.events import ar as events_router
|
from animaltrack.web.routes.events import ar as events_router
|
||||||
from animaltrack.web.routes.feed import ar as feed_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__ = [
|
__all__ = [
|
||||||
"actions_router",
|
"actions_router",
|
||||||
"animals_router",
|
"animals_router",
|
||||||
|
"api_router",
|
||||||
"eggs_router",
|
"eggs_router",
|
||||||
"events_router",
|
"events_router",
|
||||||
"feed_router",
|
"feed_router",
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
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.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
@@ -119,7 +118,7 @@ def cohort_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/animal-cohort", methods=["POST"])
|
@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."""
|
"""POST /actions/animal-cohort - Create a new animal cohort."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -198,8 +197,16 @@ async def animal_cohort(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_cohort_error(request, locations, species_list, str(e), form)
|
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
|
# Success: re-render fresh form
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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(
|
def _render_cohort_error(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -280,7 +274,7 @@ def hatch_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/hatch-recorded", methods=["POST"])
|
@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."""
|
"""POST /actions/hatch-recorded - Record a hatch event."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -346,8 +340,16 @@ async def hatch_recorded(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_hatch_error(request, locations, species_list, str(e), form)
|
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
|
# Success: re-render fresh form
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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(
|
def _render_hatch_error(
|
||||||
request: Request,
|
request: Request,
|
||||||
@@ -547,6 +536,7 @@ def tag_add_index(request: Request):
|
|||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = int(time.time() * 1000)
|
||||||
resolved_ids: list[str] = []
|
resolved_ids: list[str] = []
|
||||||
roster_hash = ""
|
roster_hash = ""
|
||||||
|
animals = []
|
||||||
|
|
||||||
if filter_str:
|
if filter_str:
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
@@ -555,6 +545,9 @@ def tag_add_index(request: Request):
|
|||||||
|
|
||||||
if resolved_ids:
|
if resolved_ids:
|
||||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
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(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -564,6 +557,7 @@ def tag_add_index(request: Request):
|
|||||||
roster_hash=roster_hash,
|
roster_hash=roster_hash,
|
||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
resolved_count=len(resolved_ids),
|
resolved_count=len(resolved_ids),
|
||||||
|
animals=animals,
|
||||||
),
|
),
|
||||||
title="Add Tag - AnimalTrack",
|
title="Add Tag - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
@@ -571,7 +565,7 @@ def tag_add_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/animal-tag-add", methods=["POST"])
|
@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."""
|
"""POST /actions/animal-tag-add - Add tag to animals."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -589,12 +583,22 @@ async def animal_tag_add(request: Request):
|
|||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
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
|
# Validation: tag required
|
||||||
if not tag:
|
if not tag:
|
||||||
return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag")
|
return _render_tag_add_error_form(request, db, filter_str, "Please enter a tag")
|
||||||
|
|
||||||
# Validation: must have animals
|
# 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")
|
return _render_tag_add_error_form(request, db, filter_str, "No animals selected")
|
||||||
|
|
||||||
# Build selection context for validation
|
# Build selection context for validation
|
||||||
@@ -605,6 +609,8 @@ async def animal_tag_add(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
from_location_id=None,
|
from_location_id=None,
|
||||||
confirmed=confirmed,
|
confirmed=confirmed,
|
||||||
|
subset_mode=subset_mode,
|
||||||
|
selected_ids=selected_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selection (check for concurrent changes)
|
# Validate selection (check for concurrent changes)
|
||||||
@@ -631,14 +637,26 @@ async def animal_tag_add(request: Request):
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
# When confirmed, re-resolve to get current server IDs
|
# Determine which IDs to use for the update
|
||||||
if confirmed:
|
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)
|
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||||
ids_to_tag = current_resolution.animal_ids
|
ids_to_tag = current_resolution.animal_ids
|
||||||
else:
|
else:
|
||||||
ids_to_tag = resolved_ids
|
ids_to_tag = list(resolved_ids)
|
||||||
|
|
||||||
# Check we still have animals
|
# Check we still have animals
|
||||||
if not ids_to_tag:
|
if not ids_to_tag:
|
||||||
@@ -667,8 +685,16 @@ async def animal_tag_add(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_tag_add_error_form(request, db, filter_str, str(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
|
# Success: re-render fresh form
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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):
|
def _render_tag_add_error_form(request, db, filter_str, error_message):
|
||||||
"""Render tag add form with error message."""
|
"""Render tag add form with error message."""
|
||||||
@@ -774,6 +787,7 @@ def tag_end_index(request: Request):
|
|||||||
resolved_ids: list[str] = []
|
resolved_ids: list[str] = []
|
||||||
roster_hash = ""
|
roster_hash = ""
|
||||||
active_tags: list[str] = []
|
active_tags: list[str] = []
|
||||||
|
animals = []
|
||||||
|
|
||||||
if filter_str:
|
if filter_str:
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
@@ -783,6 +797,9 @@ def tag_end_index(request: Request):
|
|||||||
if resolved_ids:
|
if resolved_ids:
|
||||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
roster_hash = compute_roster_hash(resolved_ids, None)
|
||||||
active_tags = _get_active_tags_for_animals(db, resolved_ids)
|
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(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -793,6 +810,7 @@ def tag_end_index(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
resolved_count=len(resolved_ids),
|
resolved_count=len(resolved_ids),
|
||||||
active_tags=active_tags,
|
active_tags=active_tags,
|
||||||
|
animals=animals,
|
||||||
),
|
),
|
||||||
title="End Tag - AnimalTrack",
|
title="End Tag - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
@@ -800,7 +818,7 @@ def tag_end_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/animal-tag-end", methods=["POST"])
|
@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."""
|
"""POST /actions/animal-tag-end - End tag on animals."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -818,12 +836,22 @@ async def animal_tag_end(request: Request):
|
|||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
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
|
# Validation: tag required
|
||||||
if not tag:
|
if not tag:
|
||||||
return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end")
|
return _render_tag_end_error_form(request, db, filter_str, "Please select a tag to end")
|
||||||
|
|
||||||
# Validation: must have animals
|
# 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")
|
return _render_tag_end_error_form(request, db, filter_str, "No animals selected")
|
||||||
|
|
||||||
# Build selection context for validation
|
# Build selection context for validation
|
||||||
@@ -834,6 +862,8 @@ async def animal_tag_end(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
from_location_id=None,
|
from_location_id=None,
|
||||||
confirmed=confirmed,
|
confirmed=confirmed,
|
||||||
|
subset_mode=subset_mode,
|
||||||
|
selected_ids=selected_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selection (check for concurrent changes)
|
# Validate selection (check for concurrent changes)
|
||||||
@@ -860,14 +890,26 @@ async def animal_tag_end(request: Request):
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
# When confirmed, re-resolve to get current server IDs
|
# Determine which IDs to use for the update
|
||||||
if confirmed:
|
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)
|
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||||
ids_to_untag = current_resolution.animal_ids
|
ids_to_untag = current_resolution.animal_ids
|
||||||
else:
|
else:
|
||||||
ids_to_untag = resolved_ids
|
ids_to_untag = list(resolved_ids)
|
||||||
|
|
||||||
# Check we still have animals
|
# Check we still have animals
|
||||||
if not ids_to_untag:
|
if not ids_to_untag:
|
||||||
@@ -896,8 +938,16 @@ async def animal_tag_end(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_tag_end_error_form(request, db, filter_str, str(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
|
# Success: re-render fresh form
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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):
|
def _render_tag_end_error_form(request, db, filter_str, error_message):
|
||||||
"""Render tag end form with error message."""
|
"""Render tag end form with error message."""
|
||||||
@@ -977,6 +1014,7 @@ def attrs_index(request: Request):
|
|||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = int(time.time() * 1000)
|
||||||
resolved_ids: list[str] = []
|
resolved_ids: list[str] = []
|
||||||
roster_hash = ""
|
roster_hash = ""
|
||||||
|
animals = []
|
||||||
|
|
||||||
if filter_str:
|
if filter_str:
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
@@ -985,6 +1023,9 @@ def attrs_index(request: Request):
|
|||||||
|
|
||||||
if resolved_ids:
|
if resolved_ids:
|
||||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
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(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -994,6 +1035,7 @@ def attrs_index(request: Request):
|
|||||||
roster_hash=roster_hash,
|
roster_hash=roster_hash,
|
||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
resolved_count=len(resolved_ids),
|
resolved_count=len(resolved_ids),
|
||||||
|
animals=animals,
|
||||||
),
|
),
|
||||||
title="Update Attributes - AnimalTrack",
|
title="Update Attributes - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
@@ -1001,7 +1043,7 @@ def attrs_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/animal-attrs", methods=["POST"])
|
@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."""
|
"""POST /actions/animal-attrs - Update attributes on animals."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -1021,6 +1063,16 @@ async def animal_attrs(request: Request):
|
|||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
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
|
# Validation: at least one attribute required
|
||||||
if not sex and not life_stage and not repro_status:
|
if not sex and not life_stage and not repro_status:
|
||||||
return _render_attrs_error_form(
|
return _render_attrs_error_form(
|
||||||
@@ -1028,7 +1080,7 @@ async def animal_attrs(request: Request):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Validation: must have animals
|
# 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")
|
return _render_attrs_error_form(request, db, filter_str, "No animals selected")
|
||||||
|
|
||||||
# Build selection context for validation
|
# Build selection context for validation
|
||||||
@@ -1039,6 +1091,8 @@ async def animal_attrs(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
from_location_id=None,
|
from_location_id=None,
|
||||||
confirmed=confirmed,
|
confirmed=confirmed,
|
||||||
|
subset_mode=subset_mode,
|
||||||
|
selected_ids=selected_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selection (check for concurrent changes)
|
# Validate selection (check for concurrent changes)
|
||||||
@@ -1067,14 +1121,26 @@ async def animal_attrs(request: Request):
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
# When confirmed, re-resolve to get current server IDs
|
# Determine which IDs to use for the update
|
||||||
if confirmed:
|
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)
|
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||||
ids_to_update = current_resolution.animal_ids
|
ids_to_update = current_resolution.animal_ids
|
||||||
else:
|
else:
|
||||||
ids_to_update = resolved_ids
|
ids_to_update = list(resolved_ids)
|
||||||
|
|
||||||
# Check we still have animals
|
# Check we still have animals
|
||||||
if not ids_to_update:
|
if not ids_to_update:
|
||||||
@@ -1108,8 +1174,16 @@ async def animal_attrs(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_attrs_error_form(request, db, filter_str, str(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
|
# Success: re-render fresh form
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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):
|
def _render_attrs_error_form(request, db, filter_str, error_message):
|
||||||
"""Render attributes form with error message."""
|
"""Render attributes form with error message."""
|
||||||
@@ -1186,6 +1247,7 @@ def outcome_index(request: Request):
|
|||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = int(time.time() * 1000)
|
||||||
resolved_ids: list[str] = []
|
resolved_ids: list[str] = []
|
||||||
roster_hash = ""
|
roster_hash = ""
|
||||||
|
animals = []
|
||||||
|
|
||||||
if filter_str:
|
if filter_str:
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
@@ -1194,6 +1256,9 @@ def outcome_index(request: Request):
|
|||||||
|
|
||||||
if resolved_ids:
|
if resolved_ids:
|
||||||
roster_hash = compute_roster_hash(resolved_ids, None)
|
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
|
# Get active products for yield items dropdown
|
||||||
product_repo = ProductRepository(db)
|
product_repo = ProductRepository(db)
|
||||||
@@ -1208,6 +1273,7 @@ def outcome_index(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
resolved_count=len(resolved_ids),
|
resolved_count=len(resolved_ids),
|
||||||
products=products,
|
products=products,
|
||||||
|
animals=animals,
|
||||||
),
|
),
|
||||||
title="Record Outcome - AnimalTrack",
|
title="Record Outcome - AnimalTrack",
|
||||||
active_nav=None,
|
active_nav=None,
|
||||||
@@ -1215,7 +1281,7 @@ def outcome_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/animal-outcome", methods=["POST"])
|
@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."""
|
"""POST /actions/animal-outcome - Record outcome for animals."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -1256,6 +1322,16 @@ async def animal_outcome(request: Request):
|
|||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
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
|
# Validation: outcome required
|
||||||
if not outcome_str:
|
if not outcome_str:
|
||||||
return _render_outcome_error_form(request, db, filter_str, "Please select an outcome")
|
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
|
# 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")
|
return _render_outcome_error_form(request, db, filter_str, "No animals selected")
|
||||||
|
|
||||||
# Build selection context for validation
|
# Build selection context for validation
|
||||||
@@ -1280,6 +1356,8 @@ async def animal_outcome(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
from_location_id=None,
|
from_location_id=None,
|
||||||
confirmed=confirmed,
|
confirmed=confirmed,
|
||||||
|
subset_mode=subset_mode,
|
||||||
|
selected_ids=selected_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selection (check for concurrent changes)
|
# Validate selection (check for concurrent changes)
|
||||||
@@ -1311,14 +1389,26 @@ async def animal_outcome(request: Request):
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
# When confirmed, re-resolve to get current server IDs
|
# Determine which IDs to use for the update
|
||||||
if confirmed:
|
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)
|
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
||||||
ids_to_update = current_resolution.animal_ids
|
ids_to_update = current_resolution.animal_ids
|
||||||
else:
|
else:
|
||||||
ids_to_update = resolved_ids
|
ids_to_update = list(resolved_ids)
|
||||||
|
|
||||||
# Check we still have animals
|
# Check we still have animals
|
||||||
if not ids_to_update:
|
if not ids_to_update:
|
||||||
@@ -1366,11 +1456,19 @@ async def animal_outcome(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_outcome_error_form(request, db, filter_str, str(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
|
# Success: re-render fresh form
|
||||||
product_repo = ProductRepository(db)
|
product_repo = ProductRepository(db)
|
||||||
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
|
products = [(p.code, p.name) for p in product_repo.list_all() if p.active]
|
||||||
|
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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):
|
def _render_outcome_error_form(request, db, filter_str, error_message):
|
||||||
"""Render outcome form with 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"])
|
@ar("/actions/animal-status-correct", methods=["POST"])
|
||||||
@require_role(UserRole.ADMIN)
|
@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)."""
|
"""POST /actions/animal-status-correct - Correct status of animals (admin-only)."""
|
||||||
db = req.app.state.db
|
db = req.app.state.db
|
||||||
form = await req.form()
|
form = await req.form()
|
||||||
@@ -1598,8 +1683,16 @@ async def animal_status_correct(req: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_status_correct_error_form(req, db, filter_str, str(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
|
# Success: re-render fresh form
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
req,
|
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):
|
def _render_status_correct_error_form(request, db, filter_str, error_message):
|
||||||
"""Render status correct form with 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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
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.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
@@ -112,7 +111,7 @@ def egg_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/product-collected", methods=["POST"])
|
@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."""
|
"""POST /actions/product-collected - Record egg collection."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -181,7 +180,7 @@ async def product_collected(request: Request):
|
|||||||
|
|
||||||
# Collect product
|
# Collect product
|
||||||
try:
|
try:
|
||||||
product_service.collect_product(
|
event = product_service.collect_product(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
actor=actor,
|
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
|
# Success: re-render form with location sticking, qty cleared
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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"])
|
@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)."""
|
"""POST /actions/product-sold - Record product sale (from Eggs page Sell tab)."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -303,7 +302,7 @@ async def product_sold(request: Request):
|
|||||||
|
|
||||||
# Sell product
|
# Sell product
|
||||||
try:
|
try:
|
||||||
product_service.sell_product(
|
event = product_service.sell_product(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
@@ -313,8 +312,15 @@ async def product_sold(request: Request):
|
|||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_sell_error(request, locations, products, product_code, str(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
|
# Success: re-render form with product sticking
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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):
|
def _render_harvest_error(request, locations, products, selected_location_id, error_message):
|
||||||
"""Render harvest form with error message.
|
"""Render harvest form with error message.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ABOUTME: Routes for event log functionality.
|
# ABOUTME: Routes for event log and event detail functionality.
|
||||||
# ABOUTME: Handles GET /event-log for viewing location event history.
|
# ABOUTME: Handles GET /event-log for location event history and GET /events/{id} for event details.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,9 +10,11 @@ from fasthtml.common import APIRouter, to_xml
|
|||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
from animaltrack.events.store import EventStore
|
||||||
from animaltrack.repositories.locations import LocationRepository
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
from animaltrack.repositories.user_defaults import UserDefaultsRepository
|
||||||
from animaltrack.web.templates import render_page
|
from animaltrack.web.templates import render_page
|
||||||
|
from animaltrack.web.templates.event_detail import event_detail_panel
|
||||||
from animaltrack.web.templates.events import event_log_list, event_log_panel
|
from animaltrack.web.templates.events import event_log_list, event_log_panel
|
||||||
|
|
||||||
# APIRouter for multi-file route organization
|
# APIRouter for multi-file route organization
|
||||||
@@ -105,3 +107,72 @@ def event_log_index(request: Request):
|
|||||||
title="Event Log - AnimalTrack",
|
title="Event Log - AnimalTrack",
|
||||||
active_nav="event_log",
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fasthtml.common import APIRouter
|
from fasthtml.common import APIRouter, add_toast
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ def feed_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/feed-given", methods=["POST"])
|
@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."""
|
"""POST /actions/feed-given - Record feed given."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -202,7 +201,7 @@ async def feed_given(request: Request):
|
|||||||
|
|
||||||
# Give feed
|
# Give feed
|
||||||
try:
|
try:
|
||||||
feed_service.give_feed(
|
event = feed_service.give_feed(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
actor=actor,
|
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
|
# Success: re-render form with location/type sticking, amount reset
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=str(
|
content=str(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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"])
|
@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."""
|
"""POST /actions/feed-purchased - Record feed purchase."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -384,7 +378,7 @@ async def feed_purchased(request: Request):
|
|||||||
|
|
||||||
# Purchase feed
|
# Purchase feed
|
||||||
try:
|
try:
|
||||||
feed_service.purchase_feed(
|
event = feed_service.purchase_feed(
|
||||||
payload=payload,
|
payload=payload,
|
||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
actor=actor,
|
actor=actor,
|
||||||
@@ -402,8 +396,15 @@ async def feed_purchased(request: Request):
|
|||||||
# Calculate total for toast
|
# Calculate total for toast
|
||||||
total_kg = bag_size_kg * bags_count
|
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
|
# Success: re-render form with fields cleared
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=str(
|
content=str(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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(
|
def _render_give_error(
|
||||||
request,
|
request,
|
||||||
|
|||||||
@@ -3,11 +3,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
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.requests import Request
|
||||||
from starlette.responses import HTMLResponse
|
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.animal_registry import AnimalRegistryProjection
|
||||||
from animaltrack.projections.event_animals import EventAnimalsProjection
|
from animaltrack.projections.event_animals import EventAnimalsProjection
|
||||||
from animaltrack.projections.intervals import IntervalProjection
|
from animaltrack.projections.intervals import IntervalProjection
|
||||||
|
from animaltrack.repositories.animals import AnimalRepository
|
||||||
from animaltrack.repositories.locations import LocationRepository
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
|
||||||
from animaltrack.selection.validation import SelectionContext, validate_selection
|
from animaltrack.selection.validation import SelectionContext, validate_selection
|
||||||
@@ -100,6 +100,7 @@ def move_index(request: Request):
|
|||||||
roster_hash = ""
|
roster_hash = ""
|
||||||
from_location_id = None
|
from_location_id = None
|
||||||
from_location_name = None
|
from_location_name = None
|
||||||
|
animals = []
|
||||||
|
|
||||||
if filter_str or not request.query_params:
|
if filter_str or not request.query_params:
|
||||||
# If no filter, default to empty (show all alive animals)
|
# If no filter, default to empty (show all alive animals)
|
||||||
@@ -110,6 +111,9 @@ def move_index(request: Request):
|
|||||||
if resolved_ids:
|
if resolved_ids:
|
||||||
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
|
from_location_id, from_location_name = _get_from_location(db, resolved_ids, ts_utc)
|
||||||
roster_hash = compute_roster_hash(resolved_ids, from_location_id)
|
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(
|
return render_page(
|
||||||
request,
|
request,
|
||||||
@@ -123,6 +127,7 @@ def move_index(request: Request):
|
|||||||
resolved_count=len(resolved_ids),
|
resolved_count=len(resolved_ids),
|
||||||
from_location_name=from_location_name,
|
from_location_name=from_location_name,
|
||||||
action=animal_move,
|
action=animal_move,
|
||||||
|
animals=animals,
|
||||||
),
|
),
|
||||||
title="Move - AnimalTrack",
|
title="Move - AnimalTrack",
|
||||||
active_nav="move",
|
active_nav="move",
|
||||||
@@ -130,7 +135,7 @@ def move_index(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@ar("/actions/animal-move", methods=["POST"])
|
@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."""
|
"""POST /actions/animal-move - Move animals to new location."""
|
||||||
db = request.app.state.db
|
db = request.app.state.db
|
||||||
form = await request.form()
|
form = await request.form()
|
||||||
@@ -149,6 +154,16 @@ async def animal_move(request: Request):
|
|||||||
# resolved_ids can be multiple values
|
# resolved_ids can be multiple values
|
||||||
resolved_ids = form.getlist("resolved_ids")
|
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
|
# Get locations for potential re-render
|
||||||
locations = LocationRepository(db).list_active()
|
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")
|
return _render_error_form(request, db, locations, filter_str, "Please select a destination")
|
||||||
|
|
||||||
# Validation: must have animals
|
# 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")
|
return _render_error_form(request, db, locations, filter_str, "No animals selected to move")
|
||||||
|
|
||||||
# Validation: destination must be different from source
|
# Validation: destination must be different from source
|
||||||
@@ -186,6 +201,8 @@ async def animal_move(request: Request):
|
|||||||
ts_utc=ts_utc,
|
ts_utc=ts_utc,
|
||||||
from_location_id=from_location_id,
|
from_location_id=from_location_id,
|
||||||
confirmed=confirmed,
|
confirmed=confirmed,
|
||||||
|
subset_mode=subset_mode,
|
||||||
|
selected_ids=selected_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate selection (check for concurrent changes)
|
# Validate selection (check for concurrent changes)
|
||||||
@@ -215,11 +232,22 @@ async def animal_move(request: Request):
|
|||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
|
||||||
# When confirmed, re-resolve to get current server IDs (per spec: "server re-resolves")
|
# Determine which IDs to use for the move
|
||||||
if confirmed:
|
if subset_mode and selected_ids:
|
||||||
# Re-resolve the filter at current timestamp to get animals still matching
|
# In subset mode, use the selected IDs from checkboxes
|
||||||
# Use max of current time and form's ts_utc to ensure we resolve at least
|
if confirmed:
|
||||||
# as late as the submission - important when moves happened after client's resolution
|
# 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)
|
current_ts = max(int(time.time() * 1000), ts_utc)
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
current_resolution = resolve_filter(db, filter_ast, current_ts)
|
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
|
# Update from_location_id based on current resolution
|
||||||
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
|
from_location_id, _ = _get_from_location(db, ids_to_move, current_ts)
|
||||||
else:
|
else:
|
||||||
ids_to_move = resolved_ids
|
ids_to_move = list(resolved_ids)
|
||||||
|
|
||||||
# Check we still have animals to move after validation
|
# Check we still have animals to move after validation
|
||||||
if not ids_to_move:
|
if not ids_to_move:
|
||||||
@@ -257,14 +285,21 @@ async def animal_move(request: Request):
|
|||||||
|
|
||||||
# Move animals
|
# Move animals
|
||||||
try:
|
try:
|
||||||
animal_service.move_animals(
|
event = animal_service.move_animals(
|
||||||
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
|
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-move"
|
||||||
)
|
)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return _render_error_form(request, db, locations, filter_str, str(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)
|
# Success: re-render fresh form (nothing sticks per spec)
|
||||||
response = HTMLResponse(
|
return HTMLResponse(
|
||||||
content=to_xml(
|
content=to_xml(
|
||||||
render_page(
|
render_page(
|
||||||
request,
|
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):
|
def _render_error_form(request, db, locations, filter_str, error_message):
|
||||||
"""Render form with error message.
|
"""Render form with error message.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
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 (
|
from monsterui.all import (
|
||||||
Alert,
|
Alert,
|
||||||
AlertT,
|
AlertT,
|
||||||
@@ -45,7 +45,6 @@ def event_datetime_field(
|
|||||||
Returns:
|
Returns:
|
||||||
Div containing the datetime picker with toggle functionality.
|
Div containing the datetime picker with toggle functionality.
|
||||||
"""
|
"""
|
||||||
toggle_id = f"{field_id}_toggle"
|
|
||||||
picker_id = f"{field_id}_picker"
|
picker_id = f"{field_id}_picker"
|
||||||
input_id = f"{field_id}_input"
|
input_id = f"{field_id}_input"
|
||||||
|
|
||||||
@@ -54,38 +53,31 @@ def event_datetime_field(
|
|||||||
picker_style = "display: block;" if has_initial else "display: none;"
|
picker_style = "display: block;" if has_initial else "display: none;"
|
||||||
toggle_text = "Use current time" if has_initial else "Set custom date"
|
toggle_text = "Use current time" if has_initial else "Set custom date"
|
||||||
|
|
||||||
# JavaScript for toggle and conversion
|
# Inline JavaScript for toggle click handler
|
||||||
script = f"""
|
toggle_onclick = f"""
|
||||||
(function() {{
|
|
||||||
var toggle = document.getElementById('{toggle_id}');
|
|
||||||
var picker = document.getElementById('{picker_id}');
|
var picker = document.getElementById('{picker_id}');
|
||||||
var input = document.getElementById('{input_id}');
|
var input = document.getElementById('{input_id}');
|
||||||
var tsField = document.querySelector('input[name="ts_utc"]');
|
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;
|
# Inline JavaScript for input change handler
|
||||||
|
input_onchange = """
|
||||||
toggle.addEventListener('click', function(e) {{
|
var tsField = document.querySelector('input[name="ts_utc"]');
|
||||||
e.preventDefault();
|
if (tsField && this.value) {
|
||||||
if (picker.style.display === 'none') {{
|
var date = new Date(this.value);
|
||||||
picker.style.display = 'block';
|
tsField.value = date.getTime().toString();
|
||||||
toggle.textContent = 'Use current time';
|
} else if (tsField) {
|
||||||
}} else {{
|
tsField.value = '0';
|
||||||
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';
|
|
||||||
}}
|
|
||||||
}});
|
|
||||||
}})();
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
@@ -96,8 +88,8 @@ def event_datetime_field(
|
|||||||
" - ",
|
" - ",
|
||||||
Span(
|
Span(
|
||||||
toggle_text,
|
toggle_text,
|
||||||
id=toggle_id,
|
|
||||||
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
cls="text-blue-400 hover:text-blue-300 cursor-pointer underline",
|
||||||
|
hx_on_click=toggle_onclick,
|
||||||
),
|
),
|
||||||
cls="text-sm",
|
cls="text-sm",
|
||||||
),
|
),
|
||||||
@@ -108,6 +100,7 @@ def event_datetime_field(
|
|||||||
type="datetime-local",
|
type="datetime-local",
|
||||||
value=initial_value,
|
value=initial_value,
|
||||||
cls="uk-input w-full mt-2",
|
cls="uk-input w-full mt-2",
|
||||||
|
hx_on_change=input_onchange,
|
||||||
),
|
),
|
||||||
P(
|
P(
|
||||||
"Select date/time for this event (leave empty for current time)",
|
"Select date/time for this event (leave empty for current time)",
|
||||||
@@ -119,7 +112,6 @@ def event_datetime_field(
|
|||||||
cls="mt-1",
|
cls="mt-1",
|
||||||
),
|
),
|
||||||
Hidden(name="ts_utc", value=initial_ts),
|
Hidden(name="ts_utc", value=initial_ts),
|
||||||
Script(script),
|
|
||||||
cls="space-y-1",
|
cls="space-y-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -544,6 +536,7 @@ def tag_add_form(
|
|||||||
resolved_count: int = 0,
|
resolved_count: int = 0,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/animal-tag-add",
|
action: Callable[..., Any] | str = "/actions/animal-tag-add",
|
||||||
|
animals: list | None = None,
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Add Tag form.
|
"""Create the Add Tag form.
|
||||||
|
|
||||||
@@ -555,22 +548,36 @@ def tag_add_form(
|
|||||||
resolved_count: Number of resolved animals.
|
resolved_count: Number of resolved animals.
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
|
animals: List of AnimalListItem for checkbox selection (optional).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for adding tags to animals.
|
Form component for adding tags to animals.
|
||||||
"""
|
"""
|
||||||
|
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||||
|
|
||||||
if resolved_ids is None:
|
if resolved_ids is None:
|
||||||
resolved_ids = []
|
resolved_ids = []
|
||||||
|
if animals is None:
|
||||||
|
animals = []
|
||||||
|
|
||||||
# Error display component
|
# Error display component
|
||||||
error_component = None
|
error_component = None
|
||||||
if error:
|
if error:
|
||||||
error_component = Alert(error, cls=AlertT.warning)
|
error_component = Alert(error, cls=AlertT.warning)
|
||||||
|
|
||||||
# Selection preview component
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
selection_preview = None
|
selection_component = None
|
||||||
if resolved_count > 0:
|
subset_mode = False
|
||||||
selection_preview = Div(
|
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(
|
P(
|
||||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||||
" animals selected",
|
" animals selected",
|
||||||
@@ -579,7 +586,7 @@ def tag_add_form(
|
|||||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||||
)
|
)
|
||||||
elif filter_str:
|
elif filter_str:
|
||||||
selection_preview = Div(
|
selection_component = Div(
|
||||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
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",
|
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,
|
value=filter_str,
|
||||||
placeholder='e.g., location:"Strip 1" species:duck',
|
placeholder='e.g., location:"Strip 1" species:duck',
|
||||||
),
|
),
|
||||||
# Selection preview
|
# Selection component (checkboxes or simple count)
|
||||||
selection_preview,
|
selection_component,
|
||||||
# Tag input
|
# Tag input
|
||||||
LabelInput(
|
LabelInput(
|
||||||
"Tag",
|
"Tag",
|
||||||
@@ -623,6 +630,7 @@ def tag_add_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
|
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Add Tag", type="submit", cls=ButtonT.primary),
|
Button("Add Tag", type="submit", cls=ButtonT.primary),
|
||||||
@@ -727,6 +735,7 @@ def tag_end_form(
|
|||||||
active_tags: list[str] | None = None,
|
active_tags: list[str] | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/animal-tag-end",
|
action: Callable[..., Any] | str = "/actions/animal-tag-end",
|
||||||
|
animals: list | None = None,
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the End Tag form.
|
"""Create the End Tag form.
|
||||||
|
|
||||||
@@ -739,24 +748,38 @@ def tag_end_form(
|
|||||||
active_tags: List of tags active on selected animals.
|
active_tags: List of tags active on selected animals.
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
|
animals: List of AnimalListItem for checkbox selection (optional).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for ending tags on animals.
|
Form component for ending tags on animals.
|
||||||
"""
|
"""
|
||||||
|
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||||
|
|
||||||
if resolved_ids is None:
|
if resolved_ids is None:
|
||||||
resolved_ids = []
|
resolved_ids = []
|
||||||
if active_tags is None:
|
if active_tags is None:
|
||||||
active_tags = []
|
active_tags = []
|
||||||
|
if animals is None:
|
||||||
|
animals = []
|
||||||
|
|
||||||
# Error display component
|
# Error display component
|
||||||
error_component = None
|
error_component = None
|
||||||
if error:
|
if error:
|
||||||
error_component = Alert(error, cls=AlertT.warning)
|
error_component = Alert(error, cls=AlertT.warning)
|
||||||
|
|
||||||
# Selection preview component
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
selection_preview = None
|
selection_component = None
|
||||||
if resolved_count > 0:
|
subset_mode = False
|
||||||
selection_preview = Div(
|
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(
|
P(
|
||||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||||
" animals selected",
|
" animals selected",
|
||||||
@@ -765,7 +788,7 @@ def tag_end_form(
|
|||||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||||
)
|
)
|
||||||
elif filter_str:
|
elif filter_str:
|
||||||
selection_preview = Div(
|
selection_component = Div(
|
||||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
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",
|
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,
|
value=filter_str,
|
||||||
placeholder="e.g., tag:layer-birds species:duck",
|
placeholder="e.g., tag:layer-birds species:duck",
|
||||||
),
|
),
|
||||||
# Selection preview
|
# Selection component (checkboxes or simple count)
|
||||||
selection_preview,
|
selection_component,
|
||||||
# Tag dropdown
|
# Tag dropdown
|
||||||
LabelSelect(
|
LabelSelect(
|
||||||
*tag_options,
|
*tag_options,
|
||||||
@@ -819,6 +842,7 @@ def tag_end_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
|
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
|
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
|
||||||
@@ -922,6 +946,7 @@ def attrs_form(
|
|||||||
resolved_count: int = 0,
|
resolved_count: int = 0,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/animal-attrs",
|
action: Callable[..., Any] | str = "/actions/animal-attrs",
|
||||||
|
animals: list | None = None,
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Update Attributes form.
|
"""Create the Update Attributes form.
|
||||||
|
|
||||||
@@ -933,22 +958,36 @@ def attrs_form(
|
|||||||
resolved_count: Number of resolved animals.
|
resolved_count: Number of resolved animals.
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
|
animals: List of AnimalListItem for checkbox selection (optional).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for updating animal attributes.
|
Form component for updating animal attributes.
|
||||||
"""
|
"""
|
||||||
|
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||||
|
|
||||||
if resolved_ids is None:
|
if resolved_ids is None:
|
||||||
resolved_ids = []
|
resolved_ids = []
|
||||||
|
if animals is None:
|
||||||
|
animals = []
|
||||||
|
|
||||||
# Error display component
|
# Error display component
|
||||||
error_component = None
|
error_component = None
|
||||||
if error:
|
if error:
|
||||||
error_component = Alert(error, cls=AlertT.warning)
|
error_component = Alert(error, cls=AlertT.warning)
|
||||||
|
|
||||||
# Selection preview component
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
selection_preview = None
|
selection_component = None
|
||||||
if resolved_count > 0:
|
subset_mode = False
|
||||||
selection_preview = Div(
|
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(
|
P(
|
||||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||||
" animals selected",
|
" animals selected",
|
||||||
@@ -957,7 +996,7 @@ def attrs_form(
|
|||||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||||
)
|
)
|
||||||
elif filter_str:
|
elif filter_str:
|
||||||
selection_preview = Div(
|
selection_component = Div(
|
||||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
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",
|
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,
|
value=filter_str,
|
||||||
placeholder="e.g., species:duck life_stage:juvenile",
|
placeholder="e.g., species:duck life_stage:juvenile",
|
||||||
),
|
),
|
||||||
# Selection preview
|
# Selection component (checkboxes or simple count)
|
||||||
selection_preview,
|
selection_component,
|
||||||
# Attribute dropdowns
|
# Attribute dropdowns
|
||||||
LabelSelect(
|
LabelSelect(
|
||||||
*sex_options,
|
*sex_options,
|
||||||
@@ -1039,6 +1078,7 @@ def attrs_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
|
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Update Attributes", type="submit", cls=ButtonT.primary),
|
Button("Update Attributes", type="submit", cls=ButtonT.primary),
|
||||||
@@ -1149,6 +1189,7 @@ def outcome_form(
|
|||||||
products: list[tuple[str, str]] | None = None,
|
products: list[tuple[str, str]] | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/animal-outcome",
|
action: Callable[..., Any] | str = "/actions/animal-outcome",
|
||||||
|
animals: list | None = None,
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Record Outcome form.
|
"""Create the Record Outcome form.
|
||||||
|
|
||||||
@@ -1161,24 +1202,39 @@ def outcome_form(
|
|||||||
products: List of (code, name) tuples for product dropdown.
|
products: List of (code, name) tuples for product dropdown.
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
|
animals: List of AnimalListItem for checkbox selection (optional).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for recording animal outcomes.
|
Form component for recording animal outcomes.
|
||||||
"""
|
"""
|
||||||
|
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||||
|
|
||||||
if resolved_ids is None:
|
if resolved_ids is None:
|
||||||
resolved_ids = []
|
resolved_ids = []
|
||||||
if products is None:
|
if products is None:
|
||||||
products = []
|
products = []
|
||||||
|
if animals is None:
|
||||||
|
animals = []
|
||||||
|
|
||||||
# Error display component
|
# Error display component
|
||||||
error_component = None
|
error_component = None
|
||||||
if error:
|
if error:
|
||||||
error_component = Alert(error, cls=AlertT.warning)
|
error_component = Alert(error, cls=AlertT.warning)
|
||||||
|
|
||||||
# Selection preview component
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
selection_preview = None
|
selection_component = None
|
||||||
if resolved_count > 0:
|
subset_mode = False
|
||||||
selection_preview = Div(
|
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(
|
P(
|
||||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||||
" animals selected",
|
" animals selected",
|
||||||
@@ -1187,7 +1243,7 @@ def outcome_form(
|
|||||||
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||||
)
|
)
|
||||||
elif filter_str:
|
elif filter_str:
|
||||||
selection_preview = Div(
|
selection_component = Div(
|
||||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
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",
|
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
|
||||||
)
|
)
|
||||||
@@ -1267,7 +1323,7 @@ def outcome_form(
|
|||||||
return Form(
|
return Form(
|
||||||
H2("Record Outcome", cls="text-xl font-bold mb-4"),
|
H2("Record Outcome", cls="text-xl font-bold mb-4"),
|
||||||
error_component,
|
error_component,
|
||||||
selection_preview,
|
selection_component,
|
||||||
# Filter field
|
# Filter field
|
||||||
LabelInput(
|
LabelInput(
|
||||||
label="Filter (DSL)",
|
label="Filter (DSL)",
|
||||||
@@ -1307,6 +1363,7 @@ def outcome_form(
|
|||||||
*resolved_id_fields,
|
*resolved_id_fields,
|
||||||
Hidden(name="roster_hash", value=roster_hash),
|
Hidden(name="roster_hash", value=roster_hash),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
|
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
|
Button("Record Outcome", type="submit", cls=ButtonT.destructive),
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ def animal_timeline_list(timeline: list[TimelineEvent]) -> Ul:
|
|||||||
|
|
||||||
|
|
||||||
def timeline_event_item(event: TimelineEvent) -> Li:
|
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)
|
badge_cls = event_type_badge_class(event.event_type)
|
||||||
summary_text = format_timeline_summary(event.event_type, event.summary)
|
summary_text = format_timeline_summary(event.event_type, event.summary)
|
||||||
time_str = format_timestamp(event.ts_utc)
|
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(summary_text, cls="text-sm text-stone-300"),
|
||||||
P(f"by {event.actor}", cls="text-xs text-stone-500"),
|
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: Base HTML template for AnimalTrack pages.
|
||||||
# ABOUTME: Provides consistent layout with MonsterUI theme and responsive nav.
|
# 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 starlette.requests import Request
|
||||||
|
|
||||||
from animaltrack.models.reference import UserRole
|
from animaltrack.models.reference import UserRole
|
||||||
@@ -14,65 +14,84 @@ from animaltrack.web.templates.sidebar import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def toast_container():
|
def EventSlideOverStyles(): # noqa: N802
|
||||||
"""Create a toast container for displaying notifications.
|
"""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.
|
#event-slide-over.open {
|
||||||
Toasts are triggered via HTMX events (HX-Trigger header with showToast).
|
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(
|
return Div(
|
||||||
id="toast-container",
|
# Backdrop
|
||||||
cls="toast toast-end toast-top z-50",
|
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(
|
def page(
|
||||||
content,
|
content,
|
||||||
title: str = "AnimalTrack",
|
title: str = "AnimalTrack",
|
||||||
@@ -88,7 +107,7 @@ def page(
|
|||||||
- Desktop sidebar (hidden on mobile)
|
- Desktop sidebar (hidden on mobile)
|
||||||
- Mobile bottom nav (hidden on desktop)
|
- Mobile bottom nav (hidden on desktop)
|
||||||
- Mobile menu drawer
|
- Mobile menu drawer
|
||||||
- Toast container for notifications
|
- Event detail slide-over panel
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content: Page content (FT components)
|
content: Page content (FT components)
|
||||||
@@ -104,12 +123,15 @@ def page(
|
|||||||
Title(title),
|
Title(title),
|
||||||
BottomNavStyles(),
|
BottomNavStyles(),
|
||||||
SidebarStyles(),
|
SidebarStyles(),
|
||||||
toast_script(),
|
EventSlideOverStyles(),
|
||||||
SidebarScript(),
|
SidebarScript(),
|
||||||
|
EventSlideOverScript(),
|
||||||
# Desktop sidebar
|
# Desktop sidebar
|
||||||
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
|
Sidebar(active_nav=active_nav, user_role=user_role, username=username),
|
||||||
# Mobile menu drawer
|
# Mobile menu drawer
|
||||||
MenuDrawer(user_role=user_role),
|
MenuDrawer(user_role=user_role),
|
||||||
|
# Event detail slide-over panel
|
||||||
|
EventSlideOver(),
|
||||||
# Main content with responsive padding/margin
|
# Main content with responsive padding/margin
|
||||||
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
|
# pb-20 for mobile bottom nav, md:pb-4 for desktop (no bottom nav)
|
||||||
# md:ml-60 to offset for desktop sidebar
|
# md:ml-60 to offset for desktop sidebar
|
||||||
@@ -120,7 +142,6 @@ def page(
|
|||||||
hx_target="body",
|
hx_target="body",
|
||||||
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
cls="pb-20 md:pb-4 md:ml-60 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||||
),
|
),
|
||||||
toast_container(),
|
|
||||||
# Mobile bottom nav
|
# Mobile bottom nav
|
||||||
BottomNav(active_id=active_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,
|
from_location_name: str | None = None,
|
||||||
error: str | None = None,
|
error: str | None = None,
|
||||||
action: Callable[..., Any] | str = "/actions/animal-move",
|
action: Callable[..., Any] | str = "/actions/animal-move",
|
||||||
|
animals: list | None = None,
|
||||||
) -> Form:
|
) -> Form:
|
||||||
"""Create the Move Animals form.
|
"""Create the Move Animals form.
|
||||||
|
|
||||||
@@ -38,12 +39,17 @@ def move_form(
|
|||||||
from_location_name: Name of source location for display.
|
from_location_name: Name of source location for display.
|
||||||
error: Optional error message to display.
|
error: Optional error message to display.
|
||||||
action: Route function or URL string for form submission.
|
action: Route function or URL string for form submission.
|
||||||
|
animals: List of AnimalListItem for checkbox selection (optional).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Form component for moving animals.
|
Form component for moving animals.
|
||||||
"""
|
"""
|
||||||
|
from animaltrack.web.templates.animal_select import animal_checkbox_list
|
||||||
|
|
||||||
if resolved_ids is None:
|
if resolved_ids is None:
|
||||||
resolved_ids = []
|
resolved_ids = []
|
||||||
|
if animals is None:
|
||||||
|
animals = []
|
||||||
|
|
||||||
# Build destination location options (exclude from_location if set)
|
# Build destination location options (exclude from_location if set)
|
||||||
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
|
location_options = [Option("Select destination...", value="", disabled=True, selected=True)]
|
||||||
@@ -59,11 +65,21 @@ def move_form(
|
|||||||
cls=AlertT.warning,
|
cls=AlertT.warning,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Selection preview component
|
# Selection component - show checkboxes if animals provided and > 1
|
||||||
selection_preview = None
|
selection_component = None
|
||||||
if resolved_count > 0:
|
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 ""
|
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(
|
P(
|
||||||
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
Span(f"{resolved_count}", cls="font-bold text-lg"),
|
||||||
f" animals selected{location_info}",
|
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",
|
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
|
||||||
)
|
)
|
||||||
elif filter_str:
|
elif filter_str:
|
||||||
selection_preview = Div(
|
selection_component = Div(
|
||||||
P("No animals match this filter", cls="text-sm text-amber-600"),
|
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",
|
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,
|
value=filter_str,
|
||||||
placeholder='e.g., location:"Strip 1" species:duck',
|
placeholder='e.g., location:"Strip 1" species:duck',
|
||||||
),
|
),
|
||||||
# Selection preview
|
# Selection component (checkboxes or simple count)
|
||||||
selection_preview,
|
selection_component,
|
||||||
# Destination dropdown
|
# Destination dropdown
|
||||||
LabelSelect(
|
LabelSelect(
|
||||||
*location_options,
|
*location_options,
|
||||||
@@ -118,6 +134,7 @@ def move_form(
|
|||||||
Hidden(name="from_location_id", value=from_location_id or ""),
|
Hidden(name="from_location_id", value=from_location_id or ""),
|
||||||
Hidden(name="resolver_version", value="v1"),
|
Hidden(name="resolver_version", value="v1"),
|
||||||
Hidden(name="confirmed", value=""),
|
Hidden(name="confirmed", value=""),
|
||||||
|
Hidden(name="subset_mode", value="true" if subset_mode else ""),
|
||||||
Hidden(name="nonce", value=str(ULID())),
|
Hidden(name="nonce", value=str(ULID())),
|
||||||
# Submit button
|
# Submit button
|
||||||
Button("Move Animals", type="submit", cls=ButtonT.primary),
|
Button("Move Animals", type="submit", cls=ButtonT.primary),
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ def SidebarScript(): # noqa: N802
|
|||||||
document.getElementById('menu-drawer').classList.add('open');
|
document.getElementById('menu-drawer').classList.add('open');
|
||||||
document.getElementById('menu-backdrop').classList.add('open');
|
document.getElementById('menu-backdrop').classList.add('open');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
// Focus the drawer for keyboard events
|
||||||
|
document.getElementById('menu-drawer').focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMenuDrawer() {
|
function closeMenuDrawer() {
|
||||||
@@ -84,13 +86,6 @@ def SidebarScript(): # noqa: N802
|
|||||||
document.getElementById('menu-backdrop').classList.remove('open');
|
document.getElementById('menu-backdrop').classList.remove('open');
|
||||||
document.body.style.overflow = '';
|
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(
|
Div(
|
||||||
id="menu-backdrop",
|
id="menu-backdrop",
|
||||||
cls="fixed inset-0 bg-black/60 z-40",
|
cls="fixed inset-0 bg-black/60 z-40",
|
||||||
onclick="closeMenuDrawer()",
|
hx_on_click="closeMenuDrawer()",
|
||||||
),
|
),
|
||||||
# Drawer panel
|
# Drawer panel
|
||||||
Div(
|
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"),
|
Span("MENU", cls="text-amber-600 font-bold tracking-wider text-sm"),
|
||||||
Button(
|
Button(
|
||||||
_close_icon(),
|
_close_icon(),
|
||||||
onclick="closeMenuDrawer()",
|
hx_on_click="closeMenuDrawer()",
|
||||||
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
cls="p-2 -mr-2 hover:bg-stone-800 rounded-lg transition-colors",
|
||||||
type="button",
|
type="button",
|
||||||
),
|
),
|
||||||
@@ -277,6 +272,8 @@ def MenuDrawer(user_role: UserRole | None = None): # noqa: N802
|
|||||||
),
|
),
|
||||||
id="menu-drawer",
|
id="menu-drawer",
|
||||||
cls="fixed top-0 right-0 bottom-0 w-72 bg-[#141413] z-50 flex flex-col shadow-2xl",
|
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",
|
cls="md:hidden",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -149,7 +149,7 @@ class TestCohortCreationSuccess:
|
|||||||
assert count_after == count_before + 3
|
assert count_after == count_before + 3
|
||||||
|
|
||||||
def test_cohort_success_returns_toast(self, client, seeded_db, location_strip1_id):
|
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(
|
resp = client.post(
|
||||||
"/actions/animal-cohort",
|
"/actions/animal-cohort",
|
||||||
data={
|
data={
|
||||||
@@ -164,8 +164,20 @@ class TestCohortCreationSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
# Toast is stored in session cookie (FastHTML's add_toast mechanism)
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
# 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:
|
class TestCohortCreationValidation:
|
||||||
@@ -363,7 +375,7 @@ class TestHatchRecordingSuccess:
|
|||||||
assert count_at_nursery >= 3
|
assert count_at_nursery >= 3
|
||||||
|
|
||||||
def test_hatch_success_returns_toast(self, client, seeded_db, location_strip1_id):
|
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(
|
resp = client.post(
|
||||||
"/actions/hatch-recorded",
|
"/actions/hatch-recorded",
|
||||||
data={
|
data={
|
||||||
@@ -375,8 +387,16 @@ class TestHatchRecordingSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
# Toast is stored in session cookie (FastHTML's add_toast mechanism)
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
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:
|
class TestHatchRecordingValidation:
|
||||||
@@ -709,7 +729,8 @@ class TestTagAddSuccess:
|
|||||||
assert tag_count >= len(animals_for_tagging)
|
assert tag_count >= len(animals_for_tagging)
|
||||||
|
|
||||||
def test_tag_add_success_returns_toast(self, client, seeded_db, 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
|
import time
|
||||||
|
|
||||||
from animaltrack.selection import compute_roster_hash
|
from animaltrack.selection import compute_roster_hash
|
||||||
@@ -730,8 +751,14 @@ class TestTagAddSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
# Toast is stored in session cookie
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
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:
|
class TestTagAddValidation:
|
||||||
@@ -898,7 +925,8 @@ class TestTagEndSuccess:
|
|||||||
assert open_after == 0
|
assert open_after == 0
|
||||||
|
|
||||||
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
|
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
|
import time
|
||||||
|
|
||||||
from animaltrack.selection import compute_roster_hash
|
from animaltrack.selection import compute_roster_hash
|
||||||
@@ -919,8 +947,14 @@ class TestTagEndSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
# Toast is stored in session cookie
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
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:
|
class TestTagEndValidation:
|
||||||
@@ -1069,7 +1103,8 @@ class TestAttrsSuccess:
|
|||||||
assert adult_count == len(animals_for_tagging)
|
assert adult_count == len(animals_for_tagging)
|
||||||
|
|
||||||
def test_attrs_success_returns_toast(self, client, seeded_db, 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
|
import time
|
||||||
|
|
||||||
from animaltrack.selection import compute_roster_hash
|
from animaltrack.selection import compute_roster_hash
|
||||||
@@ -1090,8 +1125,14 @@ class TestAttrsSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
# Toast is stored in session cookie
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
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:
|
class TestAttrsValidation:
|
||||||
@@ -1239,7 +1280,8 @@ class TestOutcomeSuccess:
|
|||||||
assert harvested_count == len(animals_for_tagging)
|
assert harvested_count == len(animals_for_tagging)
|
||||||
|
|
||||||
def test_outcome_success_returns_toast(self, client, seeded_db, 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
|
import time
|
||||||
|
|
||||||
from animaltrack.selection import compute_roster_hash
|
from animaltrack.selection import compute_roster_hash
|
||||||
@@ -1260,8 +1302,14 @@ class TestOutcomeSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
# Toast is stored in session cookie
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
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:
|
class TestOutcomeValidation:
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ class TestMoveAnimalSuccess:
|
|||||||
location_strip2_id,
|
location_strip2_id,
|
||||||
ducks_at_strip1,
|
ducks_at_strip1,
|
||||||
):
|
):
|
||||||
"""Successful move returns HX-Trigger with toast."""
|
"""Successful move returns session cookie with toast."""
|
||||||
ts_utc = int(time.time() * 1000)
|
ts_utc = int(time.time() * 1000)
|
||||||
filter_str = 'location:"Strip 1"'
|
filter_str = 'location:"Strip 1"'
|
||||||
filter_ast = parse_filter(filter_str)
|
filter_ast = parse_filter(filter_str)
|
||||||
@@ -219,8 +219,16 @@ class TestMoveAnimalSuccess:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert "HX-Trigger" in resp.headers
|
assert "set-cookie" in resp.headers
|
||||||
assert "showToast" in resp.headers["HX-Trigger"]
|
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(
|
def test_move_success_resets_form(
|
||||||
self,
|
self,
|
||||||
|
|||||||
Reference in New Issue
Block a user