feat: implement animal-tag-add and animal-tag-end routes (Step 9.1)

Add selection-based tag actions with optimistic locking:
- GET /actions/tag-add and POST /actions/animal-tag-add
- GET /actions/tag-end and POST /actions/animal-tag-end
- Form templates with selection preview and tag input/dropdown
- Diff panel for handling selection mismatches (409 response)
- Add TagProjection to the action service registry
- 16 tests covering form rendering, success, validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 12:50:38 +00:00
parent 99f2fbb964
commit 3acb731a6c
3 changed files with 1199 additions and 2 deletions

View File

@@ -14,6 +14,8 @@ from starlette.responses import HTMLResponse
from animaltrack.events.payloads import (
AnimalCohortCreatedPayload,
AnimalPromotedPayload,
AnimalTagEndedPayload,
AnimalTaggedPayload,
HatchRecordedPayload,
)
from animaltrack.events.store import EventStore
@@ -21,12 +23,23 @@ from animaltrack.projections import EventLogProjection, ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.projections.tags import TagProjection
from animaltrack.repositories.animals import AnimalRepository
from animaltrack.repositories.locations import LocationRepository
from animaltrack.repositories.species import SpeciesRepository
from animaltrack.selection import compute_roster_hash, parse_filter, resolve_filter
from animaltrack.selection.validation import SelectionContext, validate_selection
from animaltrack.services.animal import AnimalService, ValidationError
from animaltrack.web.templates import page
from animaltrack.web.templates.actions import cohort_form, hatch_form, promote_form
from animaltrack.web.templates.actions import (
cohort_form,
hatch_form,
promote_form,
tag_add_diff_panel,
tag_add_form,
tag_end_diff_panel,
tag_end_form,
)
def _create_animal_service(db: Any) -> AnimalService:
@@ -44,6 +57,7 @@ def _create_animal_service(db: Any) -> AnimalService:
registry.register(EventAnimalsProjection(db))
registry.register(IntervalProjection(db))
registry.register(EventLogProjection(db))
registry.register(TagProjection(db))
return AnimalService(db, event_store, registry)
@@ -441,6 +455,436 @@ def _render_promote_error(
)
# =============================================================================
# Add Tag
# =============================================================================
def tag_add_index(request: Request):
"""GET /actions/tag-add - Add Tag form."""
db = request.app.state.db
# Get filter from query params
filter_str = request.query_params.get("filter", "")
# Resolve selection if filter provided
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return page(
tag_add_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
),
title="Add Tag - AnimalTrack",
active_nav=None,
)
async def animal_tag_add(request: Request):
"""POST /actions/animal-tag-add - Add tag to animals."""
db = request.app.state.db
form = await request.form()
# Extract form data
filter_str = form.get("filter", "")
tag = form.get("tag", "").strip()
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
# Get timestamp - use provided or current
ts_utc_str = form.get("ts_utc", "0")
try:
ts_utc = int(ts_utc_str)
if ts_utc == 0:
ts_utc = int(time.time() * 1000)
except ValueError:
ts_utc = int(time.time() * 1000)
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Validation: tag required
if not tag:
return _render_tag_add_error_form(db, filter_str, "Please enter a tag")
# Validation: must have animals
if not resolved_ids:
return _render_tag_add_error_form(db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
filter=filter_str,
resolved_ids=list(resolved_ids),
roster_hash=roster_hash,
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
)
# Validate selection (check for concurrent changes)
result = validate_selection(db, context)
if not result.valid:
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
tag_add_diff_panel(
diff=result.diff,
filter_str=filter_str,
resolved_ids=result.resolved_ids,
roster_hash=result.roster_hash,
tag=tag,
ts_utc=ts_utc,
),
title="Add Tag - AnimalTrack",
active_nav=None,
)
),
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_tag = current_resolution.animal_ids
else:
ids_to_tag = resolved_ids
# Check we still have animals
if not ids_to_tag:
return _render_tag_add_error_form(db, filter_str, "No animals remaining to tag")
# Create payload
try:
payload = AnimalTaggedPayload(
resolved_ids=list(ids_to_tag),
tag=tag,
)
except Exception as e:
return _render_tag_add_error_form(db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# Add tag
service = _create_animal_service(db)
try:
event = service.add_tag(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-tag-add"
)
except ValidationError as e:
return _render_tag_add_error_form(db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
tag_add_form(),
title="Add Tag - AnimalTrack",
active_nav=None,
)
),
)
# 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(db, filter_str, error_message):
"""Render tag add form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
return HTMLResponse(
content=to_xml(
page(
tag_add_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
error=error_message,
),
title="Add Tag - AnimalTrack",
active_nav=None,
)
),
status_code=422,
)
# =============================================================================
# End Tag
# =============================================================================
def _get_active_tags_for_animals(db: Any, animal_ids: list[str]) -> list[str]:
"""Get tags that are active on at least one of the given animals.
Args:
db: Database connection.
animal_ids: List of animal IDs to check.
Returns:
Sorted list of unique active tag names.
"""
if not animal_ids:
return []
placeholders = ",".join("?" * len(animal_ids))
rows = db.execute(
f"""
SELECT DISTINCT tag
FROM animal_tag_intervals
WHERE animal_id IN ({placeholders})
AND end_utc IS NULL
ORDER BY tag
""",
animal_ids,
).fetchall()
return [row[0] for row in rows]
def tag_end_index(request: Request):
"""GET /actions/tag-end - End Tag form."""
db = request.app.state.db
# Get filter from query params
filter_str = request.query_params.get("filter", "")
# Resolve selection if filter provided
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
active_tags: list[str] = []
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
active_tags = _get_active_tags_for_animals(db, resolved_ids)
return page(
tag_end_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
active_tags=active_tags,
),
title="End Tag - AnimalTrack",
active_nav=None,
)
async def animal_tag_end(request: Request):
"""POST /actions/animal-tag-end - End tag on animals."""
db = request.app.state.db
form = await request.form()
# Extract form data
filter_str = form.get("filter", "")
tag = form.get("tag", "").strip()
roster_hash = form.get("roster_hash", "")
confirmed = form.get("confirmed", "") == "true"
nonce = form.get("nonce")
# Get timestamp - use provided or current
ts_utc_str = form.get("ts_utc", "0")
try:
ts_utc = int(ts_utc_str)
if ts_utc == 0:
ts_utc = int(time.time() * 1000)
except ValueError:
ts_utc = int(time.time() * 1000)
# resolved_ids can be multiple values
resolved_ids = form.getlist("resolved_ids")
# Validation: tag required
if not tag:
return _render_tag_end_error_form(db, filter_str, "Please select a tag to end")
# Validation: must have animals
if not resolved_ids:
return _render_tag_end_error_form(db, filter_str, "No animals selected")
# Build selection context for validation
context = SelectionContext(
filter=filter_str,
resolved_ids=list(resolved_ids),
roster_hash=roster_hash,
ts_utc=ts_utc,
from_location_id=None,
confirmed=confirmed,
)
# Validate selection (check for concurrent changes)
result = validate_selection(db, context)
if not result.valid:
# Mismatch detected - return 409 with diff panel
return HTMLResponse(
content=to_xml(
page(
tag_end_diff_panel(
diff=result.diff,
filter_str=filter_str,
resolved_ids=result.resolved_ids,
roster_hash=result.roster_hash,
tag=tag,
ts_utc=ts_utc,
),
title="End Tag - AnimalTrack",
active_nav=None,
)
),
status_code=409,
)
# When confirmed, re-resolve to get current server IDs
if confirmed:
current_ts = max(int(time.time() * 1000), ts_utc)
filter_ast = parse_filter(filter_str)
current_resolution = resolve_filter(db, filter_ast, current_ts)
ids_to_untag = current_resolution.animal_ids
else:
ids_to_untag = resolved_ids
# Check we still have animals
if not ids_to_untag:
return _render_tag_end_error_form(db, filter_str, "No animals remaining")
# Create payload
try:
payload = AnimalTagEndedPayload(
resolved_ids=list(ids_to_untag),
tag=tag,
)
except Exception as e:
return _render_tag_end_error_form(db, filter_str, str(e))
# Get actor from auth
auth = request.scope.get("auth")
actor = auth.username if auth else "unknown"
# End tag
service = _create_animal_service(db)
try:
event = service.end_tag(
payload, ts_utc, actor, nonce=nonce, route="/actions/animal-tag-end"
)
except ValidationError as e:
return _render_tag_end_error_form(db, filter_str, str(e))
# Success: re-render fresh form
response = HTMLResponse(
content=to_xml(
page(
tag_end_form(),
title="End Tag - AnimalTrack",
active_nav=None,
)
),
)
# 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(db, filter_str, error_message):
"""Render tag end form with error message."""
# Re-resolve to show current selection info
ts_utc = int(time.time() * 1000)
resolved_ids: list[str] = []
roster_hash = ""
active_tags: list[str] = []
if filter_str:
filter_ast = parse_filter(filter_str)
resolution = resolve_filter(db, filter_ast, ts_utc)
resolved_ids = resolution.animal_ids
if resolved_ids:
roster_hash = compute_roster_hash(resolved_ids, None)
active_tags = _get_active_tags_for_animals(db, resolved_ids)
return HTMLResponse(
content=to_xml(
page(
tag_end_form(
filter_str=filter_str,
resolved_ids=resolved_ids,
roster_hash=roster_hash,
ts_utc=ts_utc,
resolved_count=len(resolved_ids),
active_tags=active_tags,
error=error_message,
),
title="End Tag - AnimalTrack",
active_nav=None,
)
),
status_code=422,
)
# =============================================================================
# Route Registration
# =============================================================================
@@ -462,3 +906,9 @@ def register_action_routes(rt, app):
# Single animal actions
rt("/actions/promote/{animal_id}")(promote_index)
rt("/actions/animal-promote", methods=["POST"])(animal_promote)
# Selection-based actions
rt("/actions/tag-add")(tag_add_index)
rt("/actions/animal-tag-add", methods=["POST"])(animal_tag_add)
rt("/actions/tag-end")(tag_end_index)
rt("/actions/animal-tag-end", methods=["POST"])(animal_tag_end)

View File

@@ -4,7 +4,7 @@
from collections.abc import Callable
from typing import Any
from fasthtml.common import H2, Div, Form, Hidden, Option, P
from fasthtml.common import H2, Div, Form, Hidden, Option, P, Span
from monsterui.all import (
Alert,
AlertT,
@@ -18,6 +18,7 @@ from ulid import ULID
from animaltrack.models.animals import Animal
from animaltrack.models.reference import Location, Species
from animaltrack.selection.validation import SelectionDiff
# =============================================================================
# Cohort Creation Form
@@ -395,3 +396,365 @@ def promote_form(
method="post",
cls="space-y-4",
)
# =============================================================================
# Add Tag Form
# =============================================================================
def tag_add_form(
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
ts_utc: int | None = None,
resolved_count: int = 0,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-add",
) -> Form:
"""Create the Add Tag form.
Args:
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
Returns:
Form component for adding tags to animals.
"""
if resolved_ids is None:
resolved_ids = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
# Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
H2("Add Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
placeholder='e.g., location:"Strip 1" species:duck',
),
# Selection preview
selection_preview,
# Tag input
LabelInput(
"Tag",
id="tag",
name="tag",
placeholder="Enter tag name",
),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("Add Tag", type="submit", cls=ButtonT.primary),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
def tag_add_diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
tag: str,
ts_utc: int,
action: Callable[..., Any] | str = "/actions/animal-tag-add",
) -> Div:
"""Create the mismatch confirmation panel for tag add.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
tag: Tag to add.
ts_utc: Timestamp for resolution.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="tag", value=tag),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/tag-add'",
),
Button(
f"Confirm Tag ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with tagging {diff.server_count} animals as '{tag}'?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)
# =============================================================================
# End Tag Form
# =============================================================================
def tag_end_form(
filter_str: str = "",
resolved_ids: list[str] | None = None,
roster_hash: str = "",
ts_utc: int | None = None,
resolved_count: int = 0,
active_tags: list[str] | None = None,
error: str | None = None,
action: Callable[..., Any] | str = "/actions/animal-tag-end",
) -> Form:
"""Create the End Tag form.
Args:
filter_str: Current filter string (DSL).
resolved_ids: Resolved animal IDs from filter.
roster_hash: Hash of resolved selection.
ts_utc: Timestamp of resolution.
resolved_count: Number of resolved animals.
active_tags: List of tags active on selected animals.
error: Optional error message to display.
action: Route function or URL string for form submission.
Returns:
Form component for ending tags on animals.
"""
if resolved_ids is None:
resolved_ids = []
if active_tags is None:
active_tags = []
# Error display component
error_component = None
if error:
error_component = Alert(error, cls=AlertT.warning)
# Selection preview component
selection_preview = None
if resolved_count > 0:
selection_preview = Div(
P(
Span(f"{resolved_count}", cls="font-bold text-lg"),
" animals selected",
cls="text-sm",
),
cls="p-3 bg-slate-100 dark:bg-slate-800 rounded-md mb-4",
)
elif filter_str:
selection_preview = Div(
P("No animals match this filter", cls="text-sm text-amber-600"),
cls="p-3 bg-amber-50 dark:bg-amber-900/20 rounded-md mb-4",
)
# Build tag options from active_tags
tag_options = [Option("Select tag to end...", value="", disabled=True, selected=True)]
for tag in active_tags:
tag_options.append(Option(tag, value=tag))
# Hidden fields for resolved_ids (as multiple values)
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
return Form(
H2("End Tag", cls="text-xl font-bold mb-4"),
# Error message if present
error_component,
# Filter input
LabelInput(
"Filter",
id="filter",
name="filter",
value=filter_str,
placeholder="e.g., tag:layer-birds species:duck",
),
# Selection preview
selection_preview,
# Tag dropdown
LabelSelect(
*tag_options,
label="Tag to End",
id="tag",
name="tag",
)
if active_tags
else Div(
P("No active tags on selected animals", cls="text-sm text-stone-400"),
cls="p-3 bg-slate-800 rounded-md",
),
# Hidden fields for selection context
*resolved_id_fields,
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="ts_utc", value=str(ts_utc or 0)),
Hidden(name="confirmed", value=""),
Hidden(name="nonce", value=str(ULID())),
# Submit button
Button("End Tag", type="submit", cls=ButtonT.primary, disabled=not active_tags),
# Form submission via standard action/method (hx-boost handles AJAX)
action=action,
method="post",
cls="space-y-4",
)
def tag_end_diff_panel(
diff: SelectionDiff,
filter_str: str,
resolved_ids: list[str],
roster_hash: str,
tag: str,
ts_utc: int,
action: Callable[..., Any] | str = "/actions/animal-tag-end",
) -> Div:
"""Create the mismatch confirmation panel for tag end.
Args:
diff: SelectionDiff with added/removed counts.
filter_str: Original filter string.
resolved_ids: Server's resolved IDs (current).
roster_hash: Server's roster hash (current).
tag: Tag to end.
ts_utc: Timestamp for resolution.
action: Route function or URL for confirmation submit.
Returns:
Div containing the diff panel with confirm button.
"""
# Build description of changes
changes = []
if diff.removed:
changes.append(f"{len(diff.removed)} animals were removed since you loaded this page")
if diff.added:
changes.append(f"{len(diff.added)} animals were added")
changes_text = ". ".join(changes) + "." if changes else "The selection has changed."
# Build confirmation form with hidden fields
resolved_id_fields = [
Hidden(name="resolved_ids", value=animal_id) for animal_id in resolved_ids
]
confirm_form = Form(
*resolved_id_fields,
Hidden(name="filter", value=filter_str),
Hidden(name="roster_hash", value=roster_hash),
Hidden(name="tag", value=tag),
Hidden(name="ts_utc", value=str(ts_utc)),
Hidden(name="confirmed", value="true"),
Hidden(name="nonce", value=str(ULID())),
Div(
Button(
"Cancel",
type="button",
cls=ButtonT.default,
onclick="window.location.href='/actions/tag-end'",
),
Button(
f"Confirm End Tag ({diff.server_count} animals)",
type="submit",
cls=ButtonT.primary,
),
cls="flex gap-3 mt-4",
),
action=action,
method="post",
)
return Div(
Alert(
Div(
P("Selection Changed", cls="font-bold text-lg mb-2"),
P(changes_text, cls="mb-2"),
P(
f"Would you like to proceed with ending tag '{tag}' on {diff.server_count} animals?",
cls="text-sm",
),
),
cls=AlertT.warning,
),
confirm_form,
cls="space-y-4",
)