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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -584,3 +584,387 @@ class TestPromoteValidation:
|
||||
)
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Add Tag Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def animals_for_tagging(seeded_db, client, location_strip1_id):
|
||||
"""Create animals for tag testing and return their IDs."""
|
||||
# Create a cohort with 3 animals
|
||||
resp = client.post(
|
||||
"/actions/animal-cohort",
|
||||
data={
|
||||
"species": "duck",
|
||||
"location_id": location_strip1_id,
|
||||
"count": "3",
|
||||
"life_stage": "adult",
|
||||
"sex": "female",
|
||||
"origin": "purchased",
|
||||
"nonce": "test-tag-fixture-cohort-1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Get the animal IDs
|
||||
rows = seeded_db.execute(
|
||||
"SELECT animal_id FROM animal_registry WHERE origin = 'purchased' AND life_stage = 'adult' ORDER BY animal_id DESC LIMIT 3"
|
||||
).fetchall()
|
||||
return [row[0] for row in rows]
|
||||
|
||||
|
||||
class TestTagAddFormRendering:
|
||||
"""Tests for GET /actions/tag-add form rendering."""
|
||||
|
||||
def test_tag_add_form_renders(self, client):
|
||||
"""GET /actions/tag-add returns 200 with form elements."""
|
||||
resp = client.get("/actions/tag-add")
|
||||
assert resp.status_code == 200
|
||||
assert "Add Tag" in resp.text
|
||||
|
||||
def test_tag_add_form_has_filter_field(self, client):
|
||||
"""Form has filter input field."""
|
||||
resp = client.get("/actions/tag-add")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="filter"' in resp.text
|
||||
|
||||
def test_tag_add_form_has_tag_field(self, client):
|
||||
"""Form has tag input field."""
|
||||
resp = client.get("/actions/tag-add")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="tag"' in resp.text
|
||||
|
||||
def test_tag_add_form_with_filter_shows_selection(self, client, animals_for_tagging):
|
||||
"""Form with filter shows selection preview."""
|
||||
# Use species filter which is a valid filter field
|
||||
resp = client.get("/actions/tag-add?filter=species:duck")
|
||||
assert resp.status_code == 200
|
||||
# Should show selection count
|
||||
assert "selected" in resp.text.lower()
|
||||
|
||||
|
||||
class TestTagAddSuccess:
|
||||
"""Tests for successful POST /actions/animal-tag-add."""
|
||||
|
||||
def test_tag_add_creates_event(self, client, seeded_db, animals_for_tagging):
|
||||
"""POST creates AnimalTagged event when valid."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-add",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "test-tag",
|
||||
"resolved_ids": animals_for_tagging,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-nonce-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify event was created
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'AnimalTagged' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
assert event_row[0] == "AnimalTagged"
|
||||
|
||||
def test_tag_add_creates_tag_intervals(self, client, seeded_db, animals_for_tagging):
|
||||
"""POST creates tag intervals for animals."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-add",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "layer-birds",
|
||||
"resolved_ids": animals_for_tagging,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-nonce-2",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify tag intervals were created
|
||||
tag_count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'layer-birds' AND end_utc IS NULL"
|
||||
).fetchone()[0]
|
||||
assert tag_count >= len(animals_for_tagging)
|
||||
|
||||
def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging):
|
||||
"""Successful tag add returns HX-Trigger with toast."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-add",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "test-tag-toast",
|
||||
"resolved_ids": animals_for_tagging,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-nonce-3",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
|
||||
|
||||
class TestTagAddValidation:
|
||||
"""Tests for validation errors in POST /actions/animal-tag-add."""
|
||||
|
||||
def test_tag_add_missing_tag_returns_422(self, client, animals_for_tagging):
|
||||
"""Missing tag returns 422."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-add",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
# Missing tag
|
||||
"resolved_ids": animals_for_tagging,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-nonce-4",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_tag_add_no_animals_returns_422(self, client):
|
||||
"""No animals selected returns 422."""
|
||||
import time
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-add",
|
||||
data={
|
||||
"filter": "",
|
||||
"tag": "test-tag",
|
||||
# No resolved_ids
|
||||
"roster_hash": "",
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-nonce-5",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# End Tag Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tagged_animals(seeded_db, client, animals_for_tagging):
|
||||
"""Tag animals and return their IDs."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(animals_for_tagging, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# First tag the animals
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-add",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "test-end-tag",
|
||||
"resolved_ids": animals_for_tagging,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-fixture-tag-1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
return animals_for_tagging
|
||||
|
||||
|
||||
class TestTagEndFormRendering:
|
||||
"""Tests for GET /actions/tag-end form rendering."""
|
||||
|
||||
def test_tag_end_form_renders(self, client):
|
||||
"""GET /actions/tag-end returns 200 with form elements."""
|
||||
resp = client.get("/actions/tag-end")
|
||||
assert resp.status_code == 200
|
||||
assert "End Tag" in resp.text
|
||||
|
||||
def test_tag_end_form_has_filter_field(self, client):
|
||||
"""Form has filter input field."""
|
||||
resp = client.get("/actions/tag-end")
|
||||
assert resp.status_code == 200
|
||||
assert 'name="filter"' in resp.text
|
||||
|
||||
|
||||
class TestTagEndSuccess:
|
||||
"""Tests for successful POST /actions/animal-tag-end."""
|
||||
|
||||
def test_tag_end_creates_event(self, client, seeded_db, tagged_animals):
|
||||
"""POST creates AnimalTagEnded event when valid."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-end",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "test-end-tag",
|
||||
"resolved_ids": tagged_animals,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-end-nonce-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify event was created
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'AnimalTagEnded' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
assert event_row[0] == "AnimalTagEnded"
|
||||
|
||||
def test_tag_end_closes_intervals(self, client, seeded_db, tagged_animals):
|
||||
"""POST closes tag intervals for animals."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Verify intervals are open before
|
||||
open_before = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'test-end-tag' AND end_utc IS NULL"
|
||||
).fetchone()[0]
|
||||
assert open_before >= len(tagged_animals)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-end",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "test-end-tag",
|
||||
"resolved_ids": tagged_animals,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-end-nonce-2",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify intervals are closed after
|
||||
open_after = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'test-end-tag' AND end_utc IS NULL"
|
||||
).fetchone()[0]
|
||||
assert open_after == 0
|
||||
|
||||
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
|
||||
"""Successful tag end returns HX-Trigger with toast."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-end",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
"tag": "test-end-tag",
|
||||
"resolved_ids": tagged_animals,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-end-nonce-3",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
assert "showToast" in resp.headers["HX-Trigger"]
|
||||
|
||||
|
||||
class TestTagEndValidation:
|
||||
"""Tests for validation errors in POST /actions/animal-tag-end."""
|
||||
|
||||
def test_tag_end_missing_tag_returns_422(self, client, tagged_animals):
|
||||
"""Missing tag returns 422."""
|
||||
import time
|
||||
|
||||
from animaltrack.selection import compute_roster_hash
|
||||
|
||||
roster_hash = compute_roster_hash(tagged_animals, None)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-end",
|
||||
data={
|
||||
"filter": "species:duck",
|
||||
# Missing tag
|
||||
"resolved_ids": tagged_animals,
|
||||
"roster_hash": roster_hash,
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-end-nonce-4",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_tag_end_no_animals_returns_422(self, client):
|
||||
"""No animals selected returns 422."""
|
||||
import time
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
resp = client.post(
|
||||
"/actions/animal-tag-end",
|
||||
data={
|
||||
"filter": "",
|
||||
"tag": "some-tag",
|
||||
# No resolved_ids
|
||||
"roster_hash": "",
|
||||
"ts_utc": str(ts_utc),
|
||||
"nonce": "test-tag-end-nonce-5",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 422
|
||||
|
||||
Reference in New Issue
Block a user